Polish mobile settings experience

This commit is contained in:
Pulse Test
2026-08-28 11:47:57 +01:00
parent 3916854c18
commit 747f79f75e
23 changed files with 441 additions and 238 deletions
@@ -5600,6 +5600,17 @@ The settings shell is now also a governed frontend primitive boundary.
customer-facing settings-shell framing copy for navigation, search, loading,
and unsaved-change banners so `SettingsPageShell.tsx` stays a render shell
instead of re-accumulating product wording inline.
At phone widths that shell is a two-level preference workspace rather than a
compressed desktop card: the searchable grouped Settings index owns level one,
the sticky back/title bar owns level two, and content uses edge-to-edge shallow
section framing with 44-pixel interactive targets. Preference labels and their
current controls should remain in one scan row where they fit; verbose trust,
environment, and explanatory copy must be subordinated through a short summary,
bounded line clamp, or explicit detail/documentation action instead of consuming
the default viewport. Desktop retains the persistent sidebar, page description,
and roomier panel spacing. Future top-level Settings work must extend
`SettingsPageShell.tsx` and `SettingsPanel.tsx` rather than recreating a second
mobile shell or returning to nested desktop padding.
The alerts page shell now follows that same page-shell rule for feature tabs:
`frontend-modern/src/pages/Alerts.tsx` owns navigation and cross-surface
@@ -145,6 +145,9 @@ with missing, unknown, or unrelated scopes fail closed.
copy. The scope-reference action may compose frontend-primitives'
`ButtonLink` info variant for external docs-link chrome and new-tab safety;
security-privacy owns the scope trust copy, not the anchor shell.
Phone layouts may tighten this intro into the shared compact settings frame,
but scope-reference access, token inventory semantics, and credential-safety
guidance must remain visible and unchanged.
3. `frontend-modern/src/components/Settings/APITokenManager.tsx` shared with `api-contracts`: the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary.
Token-management table rows are security-facing content, but the visual
table frame and scroll shell belong to `frontend-primitives`
@@ -182,6 +185,11 @@ with missing, unknown, or unrelated scopes fail closed.
governed privacy guarantees and leave machine-facing tokens such as
`PULSE_TELEMETRY`, API fields, config keys, commands, logs, and product or
source identifiers untranslated.
At phone widths, the expanded privacy explanation may collapse to a
localized details link to the same canonical privacy document. The
telemetry state, environment override, and outbound-data control meaning
must remain directly visible; density must never imply weaker disclosure or
a different telemetry default.
8. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` shared with `frontend-primitives`: the authentication settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
9. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` shared with `frontend-primitives`: the security overview settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
Security guide links compose `ExternalTextLink`; security-privacy owns the
+46 -28
View File
@@ -1,43 +1,61 @@
{
"version": 1,
"base_sha": "973944d8c048362dac2e1d82431c3280bc7fe615",
"verified_at": "2026-08-28T09:46:30Z",
"base_sha": "3916854c18daea76b47e82cd8498016559c7ebaf",
"verified_at": "2026-08-28T10:37:02Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx",
"frontend-modern/src/components/shared/ColumnPicker.tsx",
"frontend-modern/src/components/shared/FilterBar/FilterBar.tsx",
"frontend-modern/src/components/shared/FilterBar/ViewOptionsDisclosure.tsx",
"frontend-modern/src/components/shared/FilterBar/ViewOptionsMenu.tsx",
"frontend-modern/src/components/shared/FilterToolbar.tsx",
"frontend-modern/src/index.css"
"frontend-modern/src/components/Settings/APIAccessPanel.tsx",
"frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx",
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx",
"frontend-modern/src/components/Settings/GuestDockerDiscoverySettingsCard.tsx",
"frontend-modern/src/components/Settings/SettingsPageShell.tsx",
"frontend-modern/src/components/shared/FeatureGateSection.tsx",
"frontend-modern/src/components/shared/SettingsPanel.tsx",
"frontend-modern/src/i18n/messages.de.ts",
"frontend-modern/src/i18n/messages.es.ts",
"frontend-modern/src/i18n/messages.ts",
"frontend-modern/src/index.css",
"frontend-modern/src/utils/settingsShellPresentation.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "4b7aae0fa7c6703baa7eab835e654967ac36ea4793174e721c2fee86c04b0903",
"frontend-modern/src/components/shared/ColumnPicker.tsx": "ed6903215456d6f41d69773e63fdb784276702e4389df648d736a801084c7aca",
"frontend-modern/src/components/shared/FilterBar/FilterBar.tsx": "46c6cf55cad94cf9c004c21f2bf5d4a797ad0803196d3f9ca6a2c93b451317db",
"frontend-modern/src/components/shared/FilterBar/ViewOptionsDisclosure.tsx": "c4a1f06ed5fc1416919ac808b4d862caa3c3edba5659ddfaae25695fd8c71728",
"frontend-modern/src/components/shared/FilterBar/ViewOptionsMenu.tsx": "deleted",
"frontend-modern/src/components/shared/FilterToolbar.tsx": "7b18839d12aba7cef74d934d98658a56bcc26ad46d728f86207d2d0dc25c7dd0",
"frontend-modern/src/index.css": "a9db1c5609856e9b9470c6707786b9e52cc2b4b0d9524eabed3f5b39c702a76a"
"frontend-modern/src/components/Settings/APIAccessPanel.tsx": "dfd5915a945b4d423e0c050c66593130dc82129fe08dbc071fe8386b85f6dc27",
"frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx": "8f5f2696eeb3145470d5b7ac3aa286f91a678b6c431dfe960c2f482dff0540ba",
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx": "7d28d2273109578c1718d7e6c1ed1fd3703c5caed9243f542be902ec5654a082",
"frontend-modern/src/components/Settings/GuestDockerDiscoverySettingsCard.tsx": "16abc50db9e865da1baf25151bfe7d81fcac79f62afd90564b73dbd3e58483a4",
"frontend-modern/src/components/Settings/SettingsPageShell.tsx": "5b8b67a82b2b50beb9a8d2f89b2787782e783aec55c1e6103e022a68b5b51273",
"frontend-modern/src/components/shared/FeatureGateSection.tsx": "c825da12b250926cf6ab2919fc211cab11ce6826553cb263d59341c09a2b94c7",
"frontend-modern/src/components/shared/SettingsPanel.tsx": "4e28b98fc8534c9ed2be7a43a11b0850707563e1c29af550302bba03e39141bd",
"frontend-modern/src/i18n/messages.de.ts": "4e73e3ad3f51f90f681983b00226ff808e27385f75e5f4e11a4d1b410d91d8d4",
"frontend-modern/src/i18n/messages.es.ts": "33449bd9f58debb62855bda140eff6b233a9c0c9e468f3efaa954532c03fbbee",
"frontend-modern/src/i18n/messages.ts": "8b1cf9a69df51c32e9c23c831edfa8149653d505f630c709b531271c53908085",
"frontend-modern/src/index.css": "f73a414a7e4bc0ab3806653859216e2404ac33d9f38f4bbbad426a778a062ca0",
"frontend-modern/src/utils/settingsShellPresentation.ts": "1e35ac7e362ee8745a4dbe83e3bddc8c65c2944bfa58d039a4bd0fa7685ad2c3"
},
"routes": ["/proxmox/overview"],
"routes": [
"/proxmox/overview",
"/settings/system-general",
"/settings/api",
"/settings/system-updates"
],
"viewports": [
{ "width": 1440, "height": 900 },
{ "width": 390, "height": 844 }
{ "width": 390, "height": 844 },
{ "width": 320, "height": 844 }
],
"states": [
"desktop Proxmox overview with View preferences expanded inline above Guests",
"desktop compact tray with five equal 176 by 32 pixel controls",
"desktop Columns disclosure expanded as a full-width responsive checkbox row",
"phone filter drawer with View preferences expanded as one full-width column",
"phone Columns disclosure expanded below 44 pixel preference controls"
"Proxmox overview reference density on a 390 pixel phone",
"General settings at the top of the page and after a long scroll",
"searchable grouped settings index with a filtered API Access result",
"API Access content at desktop, 390 pixel, and 320 pixel widths",
"Updates content with the shared compact section framing",
"capability-filtered index with an explicit close affordance"
],
"interactions": [
"opened and closed View preferences from the shared View trigger",
"pressed Escape from a preference child and confirmed focus returned to View",
"opened Columns at desktop width and confirmed its panel matched the tray width with no horizontal overflow",
"opened Filters then View at 390 pixels and confirmed every segmented choice split the available width evenly",
"opened Columns at 390 pixels and confirmed its options remained reachable above the mobile navigation"
"opened the settings index from the sticky mobile section bar",
"searched the settings index for API and selected API Access",
"closed the settings index without selecting a different section",
"scrolled General settings and confirmed the section bar remained sticky above content",
"confirmed Pulse mobile navigation remained visible and interactive above settings content",
"resized Settings between desktop, 390 pixel, and 320 pixel widths and confirmed no horizontal overflow"
]
}
@@ -233,6 +233,14 @@ describe('App architecture', () => {
expect(appStylesSource).not.toContain('white-space: normal;\n -webkit-box-orient');
});
it('keeps compact phone settings spacing scoped to the settings content shell', () => {
expect(appStylesSource).toContain('@media (max-width: 39.999rem)');
expect(appStylesSource).toContain(
'[data-settings-content-body] > .space-y-6 > :not([hidden]) ~ :not([hidden])',
);
expect(appStylesSource).toContain('margin-top: 0.75rem;');
});
it('keeps manual workload widths inside the existing horizontal table shell', () => {
expect(appStylesSource).toContain('.table-scroll-shell > table.workload-table--manual-widths');
expect(appStylesSource).toContain('.workload-col-resizer');
@@ -14,10 +14,12 @@ interface APIAccessPanelProps {
export const APIAccessPanel: Component<APIAccessPanelProps> = (props) => {
return (
<div class="space-y-6">
<div class="space-y-3 sm:space-y-6">
<SettingsPanel title="API Access" noPadding>
<div class="space-y-3 p-4 sm:p-6 pb-6">
<p class="text-sm text-muted">{API_TOKEN_ACCESS_PANEL_DESCRIPTION}</p>
<div class="space-y-2.5 p-2.5 sm:space-y-3 sm:p-6">
<p class="text-xs leading-relaxed text-muted sm:text-sm">
{API_TOKEN_ACCESS_PANEL_DESCRIPTION}
</p>
<ButtonLink
href={API_TOKEN_SCOPES_DOC_URL}
target="_blank"
@@ -1,5 +1,6 @@
import { Component, Accessor, Show } from 'solid-js';
import { EnvironmentLockBadge } from '@/components/shared/EnvironmentLockBadge';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { TogglePrimitive } from '@/components/shared/Toggle';
import { ENVIRONMENT_LOCK_BUTTON_TITLE } from '@/utils/environmentLockPresentation';
import {
@@ -15,72 +16,68 @@ interface DockerRuntimeSettingsCardProps {
}
export const DockerRuntimeSettingsCard: Component<DockerRuntimeSettingsCardProps> = (props) => (
<div class="rounded-xl border border-border bg-surface p-4 shadow-sm sm:p-5">
<div class="space-y-4">
<div class="space-y-1">
<h3 class="text-base font-semibold text-base-content">
{getDockerUpdateActionsPresentation().sectionTitle}
</h3>
<p class="text-sm text-muted">{getDockerUpdateActionsPresentation().sectionDescription}</p>
</div>
<div class="rounded-md border border-border bg-surface-hover p-3 sm:p-4">
<div class="flex min-w-0 items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<span
id="docker-update-actions-toggle-label"
class="text-sm font-medium text-base-content"
>
{getDockerUpdateActionsPresentation().toggleLabel}
</span>
<Show when={props.disableDockerUpdateActionsLocked()}>
<EnvironmentLockBadge
envVar={DOCKER_UPDATE_ACTIONS_ENV_VAR}
icon={(props) => (
<svg
class={props.class}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
)}
/>
</Show>
</div>
<div class="shrink-0">
<TogglePrimitive
checked={props.disableDockerUpdateActions()}
onChange={(event) =>
props.handleDisableDockerUpdateActionsChange(event.currentTarget.checked)
}
disabled={
props.disableDockerUpdateActionsLocked() || props.savingDockerUpdateActions()
}
ariaLabelledBy="docker-update-actions-toggle-label"
ariaDescribedBy="docker-update-actions-toggle-description"
title={
props.disableDockerUpdateActionsLocked() ? ENVIRONMENT_LOCK_BUTTON_TITLE : undefined
}
<SettingsPanel
title={getDockerUpdateActionsPresentation().sectionTitle}
description={getDockerUpdateActionsPresentation().sectionDescription}
noPadding
>
<div class="p-2.5 sm:p-4">
<div class="flex min-w-0 items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<span
id="docker-update-actions-toggle-label"
class="text-xs font-medium text-base-content sm:text-sm"
>
{getDockerUpdateActionsPresentation().toggleLabel}
</span>
<Show when={props.disableDockerUpdateActionsLocked()}>
<EnvironmentLockBadge
envVar={DOCKER_UPDATE_ACTIONS_ENV_VAR}
icon={(props) => (
<svg
class={props.class}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
)}
/>
</div>
</Show>
</div>
<p id="docker-update-actions-toggle-description" class="mt-2 text-xs text-muted">
{getDockerUpdateActionsPresentation().toggleDescription}
</p>
<p class="mt-1 text-xs text-muted">
{getDockerUpdateActionsPresentation().environmentHint}{' '}
<code class="break-all rounded bg-surface-hover px-1 py-0.5 text-base-content">
{DOCKER_UPDATE_ACTIONS_ENV_VAR}=true
</code>
</p>
<TogglePrimitive
checked={props.disableDockerUpdateActions()}
onChange={(event) =>
props.handleDisableDockerUpdateActionsChange(event.currentTarget.checked)
}
disabled={props.disableDockerUpdateActionsLocked() || props.savingDockerUpdateActions()}
ariaLabelledBy="docker-update-actions-toggle-label"
ariaDescribedBy="docker-update-actions-toggle-description"
title={
props.disableDockerUpdateActionsLocked() ? ENVIRONMENT_LOCK_BUTTON_TITLE : undefined
}
/>
</div>
<p
id="docker-update-actions-toggle-description"
class="mt-2 line-clamp-2 text-[11px] text-muted sm:text-xs"
>
{getDockerUpdateActionsPresentation().toggleDescription}
</p>
<p class="mt-1 min-w-0 text-[10px] text-muted sm:text-xs">
<span class="hidden sm:inline">
{getDockerUpdateActionsPresentation().environmentHint}{' '}
</span>
<code class="block max-w-full overflow-x-auto whitespace-nowrap rounded bg-surface-hover px-1 py-0.5 text-base-content sm:inline sm:overflow-visible">
{DOCKER_UPDATE_ACTIONS_ENV_VAR}=true
</code>
</p>
</div>
</div>
</SettingsPanel>
);
@@ -114,18 +114,18 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
};
return (
<div class="space-y-6">
<div class="space-y-3 sm:space-y-6">
{/* Appearance Card */}
<SettingsPanel
title={t('settings.general.appearance.title')}
noPadding
bodyClass="divide-y divide-border"
>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 sm:p-6">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center justify-between gap-2 p-2 sm:gap-4 sm:p-6">
<div class="flex min-w-0 items-center gap-3">
{/* Animated theme icon */}
<div
class={`shrink-0 relative p-2.5 rounded-md border border-border bg-surface transition-all duration-300`}
class={`relative hidden shrink-0 rounded-md border border-border bg-surface p-2.5 transition-all duration-300 sm:block`}
>
<div class="relative w-5 h-5">
<Sun
@@ -138,17 +138,17 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
/>
</div>
</div>
<div class="text-sm text-muted min-w-0">
<p class="font-medium text-base-content truncate">
<div class="min-w-0 text-sm text-muted">
<p class="text-[10px] font-medium leading-tight text-base-content sm:truncate sm:text-sm">
{t('settings.general.theme.title')}
</p>
<p class="text-xs text-muted line-clamp-2">
<p class="hidden text-xs text-muted sm:line-clamp-2">
{t('settings.general.theme.description')}
</p>
</div>
</div>
<FilterButtonGroup
class="w-full sm:w-auto sm:shrink-0 max-w-full"
class="max-w-[72%] shrink-0 [&_button]:px-1.5 [&_svg]:hidden sm:w-auto sm:max-w-full sm:[&_button]:px-3 sm:[&_svg]:block"
options={getThemePreferenceOptions()}
value={props.themePreference()}
onChange={props.setThemePreference}
@@ -157,22 +157,22 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
</div>
{/* Language Selector */}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 sm:p-6">
<div class="flex items-center gap-3 min-w-0">
<div class="shrink-0 p-2.5 rounded-md border border-border bg-surface">
<div class="flex items-center justify-between gap-2 p-2 sm:gap-4 sm:p-6">
<div class="flex min-w-0 items-center gap-3">
<div class="hidden shrink-0 rounded-md border border-border bg-surface p-2.5 sm:block">
<Languages class="w-5 h-5 text-slate-500" strokeWidth={2} />
</div>
<div class="text-sm text-muted min-w-0">
<p class="font-medium text-base-content truncate">
<div class="min-w-0 text-sm text-muted">
<p class="truncate text-xs font-medium text-base-content sm:text-sm">
{t('settings.general.language.title')}
</p>
<p class="text-xs text-muted line-clamp-2">
<p class="hidden text-xs text-muted sm:line-clamp-2">
{t('settings.general.language.description')}
</p>
</div>
</div>
<FilterButtonGroup
class="w-full sm:w-auto sm:shrink-0 max-w-full"
class="max-w-[72%] shrink-0 [&_button]:px-1.5 sm:w-auto sm:max-w-full sm:[&_button]:px-3"
options={getLocalePreferenceOptions()}
value={activeLocale()}
onChange={setLocalePreference}
@@ -182,22 +182,22 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
</div>
{/* Temperature Unit Selector */}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 sm:p-6">
<div class="flex items-center gap-3 min-w-0">
<div class="shrink-0 p-2.5 rounded-md border border-border bg-surface">
<div class="flex items-center justify-between gap-2 p-2 sm:gap-4 sm:p-6">
<div class="flex min-w-0 items-center gap-3">
<div class="hidden shrink-0 rounded-md border border-border bg-surface p-2.5 sm:block">
<Thermometer class="w-5 h-5" strokeWidth={2} />
</div>
<div class="text-sm text-muted min-w-0">
<p class="font-medium text-base-content truncate">
<div class="min-w-0 text-sm text-muted">
<p class="text-[10px] font-medium leading-tight text-base-content sm:truncate sm:text-sm">
{t('settings.general.temperature.title')}
</p>
<p class="text-xs text-muted line-clamp-2">
<p class="hidden text-xs text-muted sm:line-clamp-2">
{t('settings.general.temperature.description')}
</p>
</div>
</div>
<FilterButtonGroup
class="w-full sm:w-auto sm:shrink-0 max-w-full"
class="max-w-[72%] shrink-0 [&_button]:px-1.5 sm:w-auto sm:max-w-full sm:[&_button]:px-3"
options={TEMPERATURE_UNIT_OPTIONS}
value={temperatureStore.unit()}
onChange={(value) => temperatureStore.setUnit(value)}
@@ -206,16 +206,16 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
</div>
{/* Full-width Mode Toggle */}
<div class="flex items-center justify-between gap-4 p-4 sm:p-6">
<div class="flex items-center gap-3 min-w-0">
<div class="shrink-0 p-2.5 rounded-md border border-border bg-surface">
<div class="flex items-center justify-between gap-2 p-2 sm:gap-4 sm:p-6">
<div class="flex min-w-0 items-center gap-3">
<div class="hidden shrink-0 rounded-md border border-border bg-surface p-2.5 sm:block">
<Maximize2 class="w-5 h-5 text-slate-500" strokeWidth={2} />
</div>
<div class="text-sm text-muted min-w-0">
<p class="font-medium text-base-content truncate">
<div class="min-w-0 text-sm text-muted">
<p class="truncate text-xs font-medium text-base-content sm:text-sm">
{t('settings.general.fullWidth.title')}
</p>
<p class="text-xs text-muted line-clamp-2">
<p class="hidden text-xs text-muted sm:line-clamp-2">
{t('settings.general.fullWidth.description')}
</p>
</div>
@@ -227,7 +227,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
/>
</div>
<div class="p-4 sm:p-6">
<div class="p-2 sm:p-6">
<BrandingSettingsCard
displayName={props.reportBrandDisplayName}
setDisplayName={props.setReportBrandDisplayName}
@@ -248,7 +248,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
noPadding
bodyClass="divide-y divide-border"
>
<div class="p-4 sm:p-6 space-y-4">
<div class="space-y-3 p-2.5 sm:space-y-4 sm:p-6">
<div class="flex items-center justify-between gap-4">
<div class="flex-1 min-w-0 space-y-1">
<div class="flex items-center gap-2">
@@ -259,12 +259,19 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
<EnvironmentLockBadge envVar={TELEMETRY_ENV_VAR} />
</Show>
</div>
<p class="text-xs text-muted leading-relaxed">
<p class="hidden text-xs leading-relaxed text-muted sm:block">
{t('settings.general.telemetry.description')}{' '}
<ExternalTextLink href={PRIVACY_DOC_URL} variant="muted">
{t('settings.general.telemetry.fullDetails')}
</ExternalTextLink>
</p>
<ExternalTextLink
href={PRIVACY_DOC_URL}
variant="muted"
class="text-[11px] sm:hidden"
>
{t('settings.general.telemetry.fullDetails')}
</ExternalTextLink>
</div>
<Toggle
checked={props.telemetryEnabled()}
@@ -274,7 +281,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
/>
</div>
<div class="flex flex-wrap gap-3">
<div class="flex flex-wrap gap-2 sm:gap-3">
<Button
variant="secondary"
size="settingsActionXs"
@@ -348,8 +355,8 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
noPadding
bodyClass="divide-y divide-border"
>
<div class="p-4 sm:p-6">
<div class="space-y-4">
<div class="p-2.5 sm:p-6">
<div class="space-y-3 sm:space-y-4">
<div class="space-y-2">
<p class="text-[10px] font-bold uppercase tracking-wider text-muted">
{getPvePollingCadenceSummary(props.pvePollingInterval())}
@@ -362,7 +369,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
<div class="space-y-4 pt-2">
{/* Preset buttons */}
<FilterButtonGroup
class="sm:grid-cols-2 xl:grid-cols-5"
class="grid-cols-2 xl:grid-cols-5"
options={getPvePollingOptions()}
value={props.pvePollingSelection()}
onChange={handlePVEPollingSelection}
@@ -1,5 +1,6 @@
import { Component, Accessor, Show } from 'solid-js';
import { EnvironmentLockBadge } from '@/components/shared/EnvironmentLockBadge';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { TogglePrimitive } from '@/components/shared/Toggle';
import { ENVIRONMENT_LOCK_BUTTON_TITLE } from '@/utils/environmentLockPresentation';
import {
@@ -17,71 +18,67 @@ interface GuestDockerDiscoverySettingsCardProps {
export const GuestDockerDiscoverySettingsCard: Component<GuestDockerDiscoverySettingsCardProps> = (
props,
) => (
<div class="rounded-xl border border-border bg-surface p-4 shadow-sm sm:p-5">
<div class="space-y-4">
<div class="space-y-1">
<h3 class="text-base font-semibold text-base-content">
{getGuestDockerDiscoveryPresentation().sectionTitle}
</h3>
<p class="text-sm text-muted">{getGuestDockerDiscoveryPresentation().sectionDescription}</p>
</div>
<div class="rounded-md border border-border bg-surface-hover p-3 sm:p-4">
<div class="flex min-w-0 items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<span
id="guest-docker-inventory-toggle-label"
class="text-sm font-medium text-base-content"
>
{getGuestDockerDiscoveryPresentation().toggleLabel}
</span>
<Show when={props.guestDockerInventoryLocked()}>
<EnvironmentLockBadge
envVar={GUEST_DOCKER_INVENTORY_ENV_VAR}
icon={(props) => (
<svg
class={props.class}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
)}
/>
</Show>
</div>
<div class="shrink-0">
<TogglePrimitive
checked={props.enableProxmoxGuestDockerInventory()}
onChange={(event) =>
props.handleGuestDockerInventoryChange(event.currentTarget.checked)
}
disabled={props.guestDockerInventoryLocked() || props.savingGuestDockerInventory()}
ariaLabelledBy="guest-docker-inventory-toggle-label"
ariaDescribedBy="guest-docker-inventory-toggle-description"
title={props.guestDockerInventoryLocked() ? ENVIRONMENT_LOCK_BUTTON_TITLE : undefined}
<SettingsPanel
title={getGuestDockerDiscoveryPresentation().sectionTitle}
description={getGuestDockerDiscoveryPresentation().sectionDescription}
noPadding
>
<div class="p-2.5 sm:p-4">
<div class="flex min-w-0 items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<span
id="guest-docker-inventory-toggle-label"
class="text-xs font-medium text-base-content sm:text-sm"
>
{getGuestDockerDiscoveryPresentation().toggleLabel}
</span>
<Show when={props.guestDockerInventoryLocked()}>
<EnvironmentLockBadge
envVar={GUEST_DOCKER_INVENTORY_ENV_VAR}
icon={(props) => (
<svg
class={props.class}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
)}
/>
</div>
</Show>
</div>
<p id="guest-docker-inventory-toggle-description" class="mt-2 text-xs text-muted">
{getGuestDockerDiscoveryPresentation().toggleDescription}
</p>
<p class="mt-1 text-xs text-muted">
{getGuestDockerDiscoveryPresentation().requirementsHint}
</p>
<p class="mt-1 text-xs text-muted">
{getGuestDockerDiscoveryPresentation().environmentHint}{' '}
<code class="break-all rounded bg-surface-hover px-1 py-0.5 text-base-content">
{GUEST_DOCKER_INVENTORY_ENV_VAR}=true
</code>
</p>
<TogglePrimitive
checked={props.enableProxmoxGuestDockerInventory()}
onChange={(event) => props.handleGuestDockerInventoryChange(event.currentTarget.checked)}
disabled={props.guestDockerInventoryLocked() || props.savingGuestDockerInventory()}
ariaLabelledBy="guest-docker-inventory-toggle-label"
ariaDescribedBy="guest-docker-inventory-toggle-description"
title={props.guestDockerInventoryLocked() ? ENVIRONMENT_LOCK_BUTTON_TITLE : undefined}
/>
</div>
<p
id="guest-docker-inventory-toggle-description"
class="mt-2 line-clamp-2 text-[11px] text-muted sm:text-xs"
>
{getGuestDockerDiscoveryPresentation().toggleDescription}
</p>
<p class="mt-1 line-clamp-1 text-[11px] text-muted sm:text-xs">
{getGuestDockerDiscoveryPresentation().requirementsHint}
</p>
<p class="mt-1 min-w-0 text-[10px] text-muted sm:text-xs">
<span class="hidden sm:inline">
{getGuestDockerDiscoveryPresentation().environmentHint}{' '}
</span>
<code class="block max-w-full overflow-x-auto whitespace-nowrap rounded bg-surface-hover px-1 py-0.5 text-base-content sm:inline sm:overflow-visible">
{GUEST_DOCKER_INVENTORY_ENV_VAR}=true
</code>
</p>
</div>
</div>
</SettingsPanel>
);
@@ -41,12 +41,14 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
: props.activeTab() === itemId;
return (
<div class="min-w-0 max-w-full space-y-6">
<PageHeader title={props.headerMeta().title} description={props.headerMeta().description} />
<div data-settings-shell class="min-w-0 max-w-full space-y-0 lg:space-y-6">
<div class="hidden lg:block">
<PageHeader title={props.headerMeta().title} description={props.headerMeta().description} />
</div>
<Show when={props.hasUnsavedChanges() && props.activeTabSaveBehavior() === 'system'}>
<div class="bg-amber-50 dark:bg-amber-900 border-l-4 border-amber-500 dark:border-amber-400 rounded-r-lg shadow-sm p-4">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div class="mb-3 border-l-4 border-amber-500 bg-amber-50 p-3 shadow-sm dark:border-amber-400 dark:bg-amber-900 sm:rounded-r-lg sm:p-4 lg:mb-0">
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-4">
<div class="flex items-start gap-3">
<svg
class="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
@@ -70,7 +72,7 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
</p>
</div>
</div>
<div class="flex w-full sm:w-auto gap-3">
<div class="flex w-full gap-2 sm:w-auto sm:gap-3">
<button
type="button"
class="flex-1 sm:flex-initial px-5 py-2.5 text-sm font-medium bg-amber-600 text-white rounded-md hover:bg-amber-700 shadow-sm transition-colors"
@@ -92,25 +94,47 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
<Card
padding="none"
class="relative flex min-w-0 max-w-full overflow-hidden lg:min-h-[600px] lg:flex-row"
border={false}
class="relative flex min-w-0 max-w-full overflow-visible border-y border-border max-sm:rounded-none sm:border lg:min-h-[600px] lg:flex-row lg:overflow-hidden"
>
<div
class={`${props.isMobileMenuOpen() ? 'flex w-full flex-col' : 'hidden lg:flex lg:flex-col'} ${props.sidebarCollapsed() ? 'lg:w-16 lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:w-72 lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative z-10 max-h-[calc(100dvh-12rem)] flex-shrink-0 overflow-y-auto overscroll-contain border-b border-border bg-surface transition-all duration-200 lg:max-h-none lg:overflow-visible lg:border-b-0 lg:border-r lg:bg-transparent lg:align-top`}
data-settings-navigation
class={`${props.isMobileMenuOpen() ? 'flex w-full flex-col' : 'hidden lg:flex lg:flex-col'} ${props.sidebarCollapsed() ? 'lg:w-16 lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:w-72 lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative z-10 max-h-[calc(100dvh-8rem)] flex-shrink-0 overflow-y-auto overscroll-contain border-b border-border bg-surface transition-all duration-200 lg:max-h-none lg:overflow-visible lg:border-b-0 lg:border-r lg:bg-transparent lg:align-top`}
aria-label={shellCopy().navigationAriaLabel}
aria-expanded={!props.sidebarCollapsed()}
>
<div
class={`${props.sidebarCollapsed() ? 'px-2' : 'px-4'} space-y-5 py-5 transition-all duration-200 lg:sticky lg:top-0`}
class={`${props.sidebarCollapsed() ? 'px-2' : 'px-3 lg:px-4'} space-y-4 py-3 transition-all duration-200 lg:sticky lg:top-0 lg:space-y-5 lg:py-5`}
>
<Show when={!props.sidebarCollapsed()}>
<div class="flex items-center justify-between pb-2 border-b border-border">
<h2 class="text-sm font-semibold text-base-content">
<div class="flex min-h-11 items-center justify-between border-b border-border pb-2 lg:min-h-0">
<div
role="heading"
aria-level="1"
class="text-base font-semibold tracking-tight text-base-content lg:text-sm"
>
{shellCopy().navigationTitle}
</h2>
</div>
<button
type="button"
onClick={() => props.setIsMobileMenuOpen(false)}
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-md text-muted transition-colors active:bg-surface-hover lg:hidden"
aria-label={shellCopy().mobileCloseLabel}
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
<button
type="button"
onClick={() => props.setSidebarCollapsed(true)}
class="inline-flex h-10 w-10 items-center justify-center rounded-md transition-colors hover:bg-surface-hover lg:h-auto lg:w-auto lg:p-1"
class="hidden rounded-md p-1 transition-colors hover:bg-surface-hover lg:inline-flex"
aria-label={shellCopy().collapseSidebarLabel}
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -128,7 +152,7 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
<button
type="button"
onClick={() => props.setSidebarCollapsed(false)}
class="w-full p-2 rounded-md hover:bg-surface-hover transition-colors"
class="hidden w-full rounded-md p-2 transition-colors hover:bg-surface-hover lg:block"
aria-label={shellCopy().expandSidebarLabel}
>
<svg class="w-5 h-5 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -143,7 +167,7 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
</Show>
<div id="settings-sidebar-menu" class="space-y-4">
<Show when={!props.sidebarCollapsed()}>
<div class="px-2 pb-2">
<div class="pb-1 lg:px-2 lg:pb-2">
<SearchInput
value={props.searchQuery}
onChange={props.setSearchQuery}
@@ -168,18 +192,18 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
<For each={props.filteredTabGroups()}>
{(group) => (
<div class="mb-6 lg:mb-2 lg:space-y-2">
<div class="mb-4 lg:mb-2 lg:space-y-2">
<Show
when={
!props.sidebarCollapsed() &&
!(group.items.length === 1 && group.items[0]?.label === group.label)
}
>
<p class="px-4 lg:px-0 mb-2 lg:mb-0 text-[13px] lg:text-xs font-[500] uppercase tracking-wider text-muted">
<p class="mb-1.5 px-2 text-[11px] font-semibold uppercase tracking-wider text-muted lg:mb-0 lg:px-0 lg:text-xs lg:font-[500]">
{group.label}
</p>
</Show>
<div class="lg:bg-transparent border-y lg:border-none divide-y lg:divide-y-0 divide-border-subtle flex flex-col lg:space-y-1.5">
<div class="flex flex-col divide-y divide-border-subtle overflow-hidden rounded-md border border-border-subtle bg-surface-alt lg:space-y-1.5 lg:divide-y-0 lg:overflow-visible lg:rounded-none lg:border-0 lg:bg-transparent">
<For each={group.items}>
{(item) => {
const isActive = () => isSidebarItemActive(item.id);
@@ -188,7 +212,7 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
type="button"
aria-current={isActive() ? 'page' : undefined}
disabled={item.disabled}
class={`group flex w-full items-center ${props.sidebarCollapsed() ? 'justify-center' : 'justify-between'} lg:rounded-md ${props.sidebarCollapsed() ? 'px-2 py-2.5' : 'px-4 py-3.5 lg:px-3 lg:py-2'} text-[15px] lg:text-sm font-medium transition-colors ${item.disabled ? 'opacity-60 cursor-not-allowed text-muted' : isActive() ? 'lg:bg-blue-50 text-blue-600 dark:lg:bg-blue-900 dark:text-blue-300 lg:dark:text-blue-200 bg-surface' : ' lg:hover:bg-surface-hover hover:text-base-content active:bg-surface-hover lg:active:bg-transparent'}`}
class={`group flex min-h-12 w-full items-center ${props.sidebarCollapsed() ? 'justify-center' : 'justify-between'} lg:min-h-0 lg:rounded-md ${props.sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2.5 lg:px-3 lg:py-2'} text-sm font-medium transition-colors ${item.disabled ? 'cursor-not-allowed text-muted opacity-60' : isActive() ? 'bg-surface text-blue-600 dark:text-blue-300 lg:bg-blue-50 lg:dark:bg-blue-900 lg:dark:text-blue-200' : 'hover:text-base-content active:bg-surface-hover lg:hover:bg-surface-hover lg:active:bg-transparent'}`}
onClick={() => {
if (item.disabled) return;
props.setActiveTab(item.id);
@@ -196,9 +220,9 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
}}
title={props.sidebarCollapsed() ? item.label : undefined}
>
<div class="flex items-center gap-3.5 lg:gap-2.5 w-full">
<div class="flex w-full items-center gap-3 lg:gap-2.5">
<div
class={`flex items-center justify-center rounded-md lg:rounded-none w-8 h-8 lg:w-auto lg:h-auto ${isActive() ? 'bg-blue-100 dark:bg-blue-900 lg:bg-transparent text-blue-600 dark:text-blue-400' : 'bg-surface-alt lg:bg-transparent text-muted lg:text-inherit'}`}
class={`flex h-8 w-8 items-center justify-center rounded-md lg:h-auto lg:w-auto lg:rounded-none ${isActive() ? 'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-400 lg:bg-transparent' : 'bg-surface text-muted lg:bg-transparent lg:text-inherit'}`}
>
<item.icon
class="w-5 h-5 lg:w-4 lg:h-4"
@@ -233,14 +257,17 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
<div
data-settings-content
class={`min-w-0 flex-1 overflow-hidden ${props.isMobileMenuOpen() ? 'hidden lg:block' : 'block'}`}
class={`min-w-0 flex-1 overflow-visible lg:overflow-hidden ${props.isMobileMenuOpen() ? 'hidden lg:block' : 'block'}`}
>
<Show when={props.flatTabs().length > 0}>
<div class="lg:hidden sticky top-0 z-40 bg-surface/95 border-b border-border-subtle px-3 py-2.5 flex items-center shadow-none">
<div class="sticky top-0 z-40 flex min-h-12 items-center border-b border-border-subtle bg-surface/95 px-2 backdrop-blur lg:hidden">
<button
type="button"
onClick={() => props.setIsMobileMenuOpen(true)}
class="flex min-h-11 items-center gap-1.5 rounded-md px-2 py-1.5 font-medium text-blue-600 transition-colors active:bg-blue-50 dark:text-blue-400 dark:active:bg-blue-900"
onClick={() => {
props.setSidebarCollapsed(false);
props.setIsMobileMenuOpen(true);
}}
class="flex min-h-11 items-center gap-1 rounded-md px-2 py-1.5 text-sm font-medium text-blue-600 transition-colors active:bg-blue-50 dark:text-blue-400 dark:active:bg-blue-900"
>
<svg
class="h-5 w-5 -ml-1 flex-shrink-0"
@@ -253,7 +280,11 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
</svg>
{shellCopy().mobileBackLabel}
</button>
<div class="ml-auto font-semibold text-base-content pr-3">
<div
role="heading"
aria-level="1"
class="ml-auto truncate pr-2 text-sm font-semibold text-base-content"
>
<Show keyed when={props.flatTabs().find((tab) => tab.id === props.activeTab())}>
{(tab) => tab.label}
</Show>
@@ -262,7 +293,8 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
</Show>
<div
class={`min-w-0 ${infrastructureWorkspaceActive() ? 'p-3 sm:p-4 lg:p-5' : 'p-4 sm:p-6 lg:p-8'}`}
data-settings-content-body
class={`min-w-0 ${infrastructureWorkspaceActive() ? 'p-0 sm:p-4 lg:p-5' : 'py-3 sm:p-6 lg:p-8'}`}
>
{props.children}
</div>
@@ -91,7 +91,10 @@ describe('GeneralSettingsPanel localization', () => {
expect(screen.getByText('Monitoring cadence')).toBeInTheDocument();
expect(screen.getByText('Current cadence: 30 seconds (under a minute)')).toBeInTheDocument();
expect(screen.getByText('Docker / Podman updates')).toBeInTheDocument();
expect(screen.getByText('PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true')).toHaveClass('break-all');
expect(screen.getByText('PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true')).toHaveClass(
'overflow-x-auto',
'whitespace-nowrap',
);
expect(screen.getByRole('button', { name: 'English' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Deutsch' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Español' })).toBeInTheDocument();
@@ -47,6 +47,13 @@ describe('SettingsPageShell mobile navigation', () => {
expect(screen.getAllByText('API Access')).toHaveLength(1);
expect(screen.getByRole('button', { name: 'Settings' })).toHaveClass('min-h-11');
const shell = document.querySelector('[data-settings-shell]');
const navigation = document.querySelector('[data-settings-navigation]');
const contentBody = document.querySelector('[data-settings-content-body]');
expect(shell).toHaveClass('space-y-0', 'lg:space-y-6');
expect(navigation).toHaveClass('max-h-[calc(100dvh-8rem)]');
expect(contentBody).toHaveClass('min-w-0', 'p-0', 'sm:p-4', 'lg:p-5');
setActiveTab('api');
await waitFor(() => {
@@ -85,8 +92,63 @@ describe('SettingsPageShell mobile navigation', () => {
));
const content = container.querySelector('[data-settings-content]');
expect(content).toHaveClass('min-w-0', 'flex-1', 'overflow-hidden', 'block');
expect(content).toHaveClass(
'min-w-0',
'flex-1',
'overflow-visible',
'lg:overflow-hidden',
'block',
);
expect(content).not.toHaveClass('animate-slideInRight');
expect(content?.lastElementChild).toHaveClass('min-w-0');
expect(content?.lastElementChild).toHaveClass('py-3', 'sm:p-6', 'lg:p-8');
expect(content?.lastElementChild).not.toHaveClass('p-4');
});
it('expands a collapsed desktop sidebar before opening the phone settings index', async () => {
const [activeTab] = createSignal<SettingsTab>('api');
const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(true);
const [searchQuery, setSearchQuery] = createSignal('');
render(() => (
<SettingsPageShell
headerMeta={() => ({ title: 'API Access', description: 'Manage API access.' })}
hasUnsavedChanges={() => false}
activeTabSaveBehavior={() => undefined}
saveSettings={() => undefined}
discardChanges={() => undefined}
isMobileMenuOpen={mobileMenuOpen}
setIsMobileMenuOpen={setMobileMenuOpen}
sidebarCollapsed={sidebarCollapsed}
setSidebarCollapsed={setSidebarCollapsed}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
filteredTabGroups={() => [{ id: 'infrastructure', label: 'Settings', items: tabs }]}
flatTabs={() => tabs}
activeTab={activeTab}
setActiveTab={() => undefined}
isPro={() => false}
>
<div>Panel content</div>
</SettingsPageShell>
));
expect(sidebarCollapsed()).toBe(true);
await screen.getByRole('button', { name: 'Settings' }).click();
await waitFor(() => {
expect(sidebarCollapsed()).toBe(false);
expect(mobileMenuOpen()).toBe(true);
expect(screen.getByPlaceholderText('Search settings...')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'Collapse settings navigation' })).toHaveClass(
'hidden',
'lg:inline-flex',
);
await screen.getByRole('button', { name: 'Close settings navigation' }).click();
await waitFor(() => expect(mobileMenuOpen()).toBe(false));
});
});
@@ -156,11 +156,13 @@ describe('settings architecture guardrails', () => {
expect(settingsPageShellSource).toContain(
'<PageHeader title={props.headerMeta().title} description={props.headerMeta().description} />',
);
expect(settingsPageShellSource).toContain('max-h-[calc(100dvh-12rem)]');
expect(settingsPageShellSource).toContain('max-h-[calc(100dvh-8rem)]');
expect(settingsPageShellSource).toContain('overflow-y-auto overscroll-contain');
expect(settingsPageShellSource).toContain('lg:min-h-[600px]');
expect(settingsPageShellSource).toContain('data-settings-content');
expect(settingsPageShellSource).toContain('class={`min-w-0 flex-1 overflow-hidden');
expect(settingsPageShellSource).toContain(
'class={`min-w-0 flex-1 overflow-visible lg:overflow-hidden',
);
expect(settingsPageShellSource).not.toContain('animate-slideInRight');
expect(settingsPageShellSource).not.toContain('min-h-[600px]">');
expect(settingsDialogsSource).toContain('export const SettingsDialogs');
@@ -10,6 +10,7 @@ import {
getSettingsNavItem,
SETTINGS_NAV_GROUPS,
} from '../settingsNavCatalog';
import { getSettingsShellCopy } from '@/utils/settingsShellPresentation';
describe('settings localization catalog', () => {
afterEach(() => {
@@ -49,6 +50,12 @@ describe('settings localization catalog', () => {
expect(getSettingsNavItem('support-diagnostics', 'es')?.label).toBe('Diagnóstico y salud');
});
it('localizes the mobile settings index close action', () => {
expect(getSettingsShellCopy('en').mobileCloseLabel).toBe('Close settings navigation');
expect(getSettingsShellCopy('de').mobileCloseLabel).toBe('Einstellungsnavigation schließen');
expect(getSettingsShellCopy('es').mobileCloseLabel).toBe('Cerrar navegación de ajustes');
});
it('localizes the value-first Remote Access header in every catalog locale', () => {
expect(SETTINGS_HEADER_META['system-relay']).toEqual({
title: 'Remote Access',
@@ -33,18 +33,20 @@ export interface FeatureGateSectionProps {
* stay identical across every surface that gates a feature.
*/
export const FeatureGateSection: Component<FeatureGateSectionProps> = (props) => (
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex flex-1 items-start gap-3 text-center sm:text-left">
<div class="flex items-center gap-2.5 sm:gap-4">
<div class="flex min-w-0 flex-1 items-start gap-2 text-left sm:gap-3">
<Show when={props.icon}>
<span class="mt-0.5 flex-shrink-0 text-blue-500">{props.icon}</span>
</Show>
<div class="flex-1">
<h4 class="text-base font-semibold text-base-content">{props.title}</h4>
<p class="mt-1 text-sm text-muted">{props.body}</p>
<div class="min-w-0 flex-1">
<h4 class="text-sm font-semibold text-base-content sm:text-base">{props.title}</h4>
<p class="mt-0.5 line-clamp-2 text-[11px] leading-snug text-muted sm:mt-1 sm:text-sm sm:leading-normal">
{props.body}
</p>
</div>
</div>
<Show when={props.showUpgradePrompts}>
<div class="flex flex-col sm:flex-row items-center gap-2">
<div class="flex shrink-0 items-center gap-2">
<UpgradeButtonLink destination={props.upgradeDestination} tone={props.upgradeButtonTone}>
{props.upgradeLabel ?? UPGRADE_ACTION_LABEL}
</UpgradeButtonLink>
@@ -28,32 +28,35 @@ export function SettingsPanel(props: SettingsPanelProps) {
return (
<Card
data-settings-panel
padding="none"
tone={local.tone ?? 'default'}
class={`overflow-hidden border border-border ${local.class ?? ''}`.trim()}
border={false}
{...rest}
>
<div class="px-3 py-3 sm:px-6 sm:py-4 border-b border-border bg-surface-alt">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-col gap-1 flex-1">
<div class="border-b border-border bg-surface-alt px-2.5 py-2 sm:px-6 sm:py-4">
<div class="flex items-start gap-2 sm:items-center sm:gap-3">
<div class="flex min-w-0 flex-1 flex-col gap-0.5 sm:gap-1">
<h2
id={local.titleId}
class="text-sm sm:text-base tracking-tight font-semibold text-base-content dark:text-slate-100"
class="text-[11px] font-semibold uppercase tracking-wide text-base-content dark:text-slate-100 sm:text-base sm:normal-case sm:tracking-tight"
>
{local.title}
</h2>
<Show when={local.description}>
<p class="text-xs sm:text-sm text-muted dark:text-slate-200">{local.description}</p>
<p class="line-clamp-1 text-[11px] text-muted dark:text-slate-200 sm:text-sm">
{local.description}
</p>
</Show>
</div>
<Show when={local.action}>
<div class="w-full sm:w-auto">{local.action}</div>
<div class="w-auto shrink-0">{local.action}</div>
</Show>
</div>
</div>
<div
class={`${local.noPadding ? '' : 'p-4 sm:p-6'} ${local.bodyClass ?? (local.noPadding ? '' : 'space-y-6')}`}
class={`${local.noPadding ? '' : 'p-2.5 sm:p-6'} ${local.bodyClass ?? (local.noPadding ? '' : 'space-y-3 sm:space-y-6')}`}
>
{local.children}
</div>
@@ -312,6 +312,7 @@ import filterCatalogSource from '@/components/shared/FilterBar/filterCatalog.ts?
import filterBarOptionPresentationSource from '@/components/shared/FilterBar/filterOptionPresentation.tsx?raw';
import filterBarIndexSource from '@/components/shared/FilterBar/index.ts?raw';
import viewOptionsDisclosureSource from '@/components/shared/FilterBar/ViewOptionsDisclosure.tsx?raw';
import settingsPanelSource from '@/components/shared/SettingsPanel.tsx?raw';
import storagePageControlsSource from '@/components/Storage/StoragePageControls.tsx?raw';
import orgSwitcherSource from '@/components/OrgSwitcher.tsx?raw';
import resourceDetailDrawerOverviewTabSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw';
@@ -8711,6 +8712,15 @@ describe('shared primitive guardrails', () => {
expect(inlineDetailTableRowSource).toContain('max-w-[calc(100vw-3.5rem)]');
});
it('keeps shared settings panels dense without forking their desktop framing', () => {
expect(settingsPanelSource).toContain('data-settings-panel');
expect(settingsPanelSource).toContain('px-2.5 py-2 sm:px-6 sm:py-4');
expect(settingsPanelSource).toContain("'p-2.5 sm:p-6'");
expect(settingsPanelSource).toContain("'space-y-3 sm:space-y-6'");
expect(featureGateSectionSource).toContain('flex items-center gap-2.5 sm:gap-4');
expect(featureGateSectionSource).toContain('line-clamp-2 text-[11px]');
});
it('keeps object drawers operator-first across platform implementations', () => {
const overviewConsumers = [
'src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx',
+1
View File
@@ -525,6 +525,7 @@ export const DE_MESSAGE_OVERRIDES = {
'settings.shell.expandSidebarLabel': 'Einstellungsnavigation ausklappen',
'settings.shell.loading': 'Einstellungen werden geladen...',
'settings.shell.mobileBackLabel': 'Einstellungen',
'settings.shell.mobileCloseLabel': 'Einstellungsnavigation schließen',
'settings.shell.navigationAriaLabel': 'Einstellungsnavigation',
'settings.shell.navigationTitle': 'Einstellungen',
'settings.shell.saveChangesLabel': 'Aenderungen speichern',
+1
View File
@@ -519,6 +519,7 @@ export const ES_MESSAGE_OVERRIDES = {
'settings.shell.expandSidebarLabel': 'Expandir navegación de ajustes',
'settings.shell.loading': 'Cargando ajustes...',
'settings.shell.mobileBackLabel': 'Ajustes',
'settings.shell.mobileCloseLabel': 'Cerrar navegación de ajustes',
'settings.shell.navigationAriaLabel': 'Navegación de ajustes',
'settings.shell.navigationTitle': 'Ajustes',
'settings.shell.saveChangesLabel': 'Guardar cambios',
+1
View File
@@ -509,6 +509,7 @@ export const EN_MESSAGES = {
'settings.shell.expandSidebarLabel': 'Expand settings navigation',
'settings.shell.loading': 'Loading settings...',
'settings.shell.mobileBackLabel': 'Settings',
'settings.shell.mobileCloseLabel': 'Close settings navigation',
'settings.shell.navigationAriaLabel': 'Settings navigation',
'settings.shell.navigationTitle': 'Settings',
'settings.shell.saveChangesLabel': 'Save Changes',
+9
View File
@@ -242,6 +242,15 @@
-webkit-overflow-scrolling: touch;
}
/* Settings uses the same compact mobile rhythm as the platform overviews:
the route shell owns the inter-section spacing so panel implementations do
not each recreate a phone breakpoint. Desktop keeps the authored spacing. */
@media (max-width: 39.999rem) {
[data-settings-content-body] > .space-y-6 > :not([hidden]) ~ :not([hidden]) {
margin-top: 0.75rem;
}
}
.table-scroll-shell {
container-type: inline-size;
contain: paint;
@@ -16,6 +16,7 @@ describe('settingsShellPresentation', () => {
searchPlaceholder: 'Search settings...',
searchShortcutHint: undefined,
mobileBackLabel: 'Settings',
mobileCloseLabel: 'Close settings navigation',
collapseSidebarLabel: 'Collapse settings navigation',
expandSidebarLabel: 'Expand settings navigation',
};
@@ -52,6 +53,7 @@ describe('settingsShellPresentation', () => {
navigationAriaLabel: 'Einstellungsnavigation',
navigationTitle: 'Einstellungen',
searchPlaceholder: 'Einstellungen suchen...',
mobileCloseLabel: 'Einstellungsnavigation schließen',
});
expect(getSettingsUnsavedChangesBanner('de')).toMatchObject({
title: 'Nicht gespeicherte Aenderungen',
@@ -7,6 +7,7 @@ export function getSettingsShellCopy(locale?: SupportedLocale) {
searchPlaceholder: t('settings.shell.searchPlaceholder', {}, locale),
searchShortcutHint: undefined,
mobileBackLabel: t('settings.shell.mobileBackLabel', {}, locale),
mobileCloseLabel: t('settings.shell.mobileCloseLabel', {}, locale),
collapseSidebarLabel: t('settings.shell.collapseSidebarLabel', {}, locale),
expandSidebarLabel: t('settings.shell.expandSidebarLabel', {}, locale),
} as const;
@@ -109,8 +109,9 @@ test.describe('Settings shell consistency', () => {
await page.waitForURL(/\/settings/, { timeout: 15_000 });
if (isMobile) {
// Mobile keeps the settings navigation behind the Settings drawer
// trigger instead of a persistent sidebar.
// Mobile is a two-level workspace: the compact section header opens
// the full settings index, and selecting the active section returns
// to its content without leaving the route.
await page
.getByRole('main')
.getByRole('button', { name: 'Settings', exact: true })
@@ -124,6 +125,24 @@ test.describe('Settings shell consistency', () => {
const searchInput = page.getByPlaceholder('Search settings...');
await expect(searchInput, `${panel.route} should keep the shared settings search`).toBeVisible();
if (isMobile) {
await expect(
navigation.getByRole('heading', { level: 1, name: 'Settings' }),
`${panel.route} should label the mobile settings index`,
).toBeVisible();
const activeSection = navigation.locator('button[aria-current="page"]');
if ((await activeSection.count()) === 1) {
await activeSection.click();
} else {
// A valid direct route can be omitted from the index by capability
// or feature visibility. The index must still be dismissible.
await navigation
.getByRole('button', { name: 'Close settings navigation', exact: true })
.click();
}
await expect(navigation).toBeHidden();
}
const pageHeading = page.getByRole('heading', { level: 1, name: panel.title });
await expect(pageHeading, `${panel.route} should render the canonical page-shell heading`).toBeVisible();