From 747f79f75e044cd5383cc344c0d858b21c028be3 Mon Sep 17 00:00:00 2001 From: Pulse Test Date: Fri, 28 Aug 2026 11:47:57 +0100 Subject: [PATCH] Polish mobile settings experience --- .../subsystems/frontend-primitives.md | 11 ++ .../internal/subsystems/security-privacy.md | 8 ++ frontend-modern/browser-verification.json | 74 +++++++---- .../src/__tests__/App.architecture.test.ts | 8 ++ .../components/Settings/APIAccessPanel.tsx | 8 +- .../Settings/DockerRuntimeSettingsCard.tsx | 125 +++++++++--------- .../Settings/GeneralSettingsPanel.tsx | 77 ++++++----- .../GuestDockerDiscoverySettingsCard.tsx | 123 +++++++++-------- .../components/Settings/SettingsPageShell.tsx | 84 ++++++++---- ...GeneralSettingsPanel.localization.test.tsx | 5 +- .../SettingsPageShell.mobile.test.tsx | 64 ++++++++- .../__tests__/settingsArchitecture.test.ts | 6 +- .../__tests__/settingsLocalization.test.ts | 7 + .../components/shared/FeatureGateSection.tsx | 14 +- .../src/components/shared/SettingsPanel.tsx | 17 ++- .../SharedPrimitives.guardrails.test.ts | 10 ++ frontend-modern/src/i18n/messages.de.ts | 1 + frontend-modern/src/i18n/messages.es.ts | 1 + frontend-modern/src/i18n/messages.ts | 1 + frontend-modern/src/index.css | 9 ++ .../settingsShellPresentation.test.ts | 2 + .../src/utils/settingsShellPresentation.ts | 1 + .../15-settings-shell-consistency.spec.ts | 23 +++- 23 files changed, 441 insertions(+), 238 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 07ba19d57..20b497a55 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index c34f2c931..7e87df6b9 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index c04764eb6..d4ff32f4b 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 5780a0091..42a8d1138 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -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'); diff --git a/frontend-modern/src/components/Settings/APIAccessPanel.tsx b/frontend-modern/src/components/Settings/APIAccessPanel.tsx index 6facb4bca..2f67227a7 100644 --- a/frontend-modern/src/components/Settings/APIAccessPanel.tsx +++ b/frontend-modern/src/components/Settings/APIAccessPanel.tsx @@ -14,10 +14,12 @@ interface APIAccessPanelProps { export const APIAccessPanel: Component = (props) => { return ( -
+
-
-

{API_TOKEN_ACCESS_PANEL_DESCRIPTION}

+
+

+ {API_TOKEN_ACCESS_PANEL_DESCRIPTION} +

= (props) => ( -
-
-
-

- {getDockerUpdateActionsPresentation().sectionTitle} -

-

{getDockerUpdateActionsPresentation().sectionDescription}

-
- -
-
-
- - {getDockerUpdateActionsPresentation().toggleLabel} - - - ( - - - - )} - /> - -
-
- - 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 - } + +
+
+
+ + {getDockerUpdateActionsPresentation().toggleLabel} + + + ( + + + + )} /> -
+
-

- {getDockerUpdateActionsPresentation().toggleDescription} -

-

- {getDockerUpdateActionsPresentation().environmentHint}{' '} - - {DOCKER_UPDATE_ACTIONS_ENV_VAR}=true - -

+ + 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 + } + />
+

+ {getDockerUpdateActionsPresentation().toggleDescription} +

+

+ + + {DOCKER_UPDATE_ACTIONS_ENV_VAR}=true + +

-
+ ); diff --git a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx index cc89e3c59..583158140 100644 --- a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx @@ -114,18 +114,18 @@ export const GeneralSettingsPanel: Component = (props }; return ( -
+
{/* Appearance Card */} -
-
+
+
{/* Animated theme icon */} -
-

+

+

{t('settings.general.theme.title')}

-

+

= (props
{/* Language Selector */} -
-
-
+
+
+ -
-

+

+

{t('settings.general.language.title')}

-

+

= (props
{/* Temperature Unit Selector */} -
-
-
+
+
+ -
-

+

+

{t('settings.general.temperature.title')}

-

+

temperatureStore.setUnit(value)} @@ -206,16 +206,16 @@ export const GeneralSettingsPanel: Component = (props
{/* Full-width Mode Toggle */} -
-
-
+
+
+ -
-

+

+

{t('settings.general.fullWidth.title')}

-

+

@@ -227,7 +227,7 @@ export const GeneralSettingsPanel: Component = (props />
-
+
= (props noPadding bodyClass="divide-y divide-border" > -
+
@@ -259,12 +259,19 @@ export const GeneralSettingsPanel: Component = (props
-

+

+ + {t('settings.general.telemetry.fullDetails')} +
= (props />
-
+
-
+
tab.id === props.activeTab())}> {(tab) => tab.label} @@ -262,7 +293,8 @@ export const SettingsPageShell: Component = (props) => {
{props.children}
diff --git a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx index d02ec5df0..d37365766 100644 --- a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx @@ -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(); diff --git a/frontend-modern/src/components/Settings/__tests__/SettingsPageShell.mobile.test.tsx b/frontend-modern/src/components/Settings/__tests__/SettingsPageShell.mobile.test.tsx index 46a3e62fd..1c97dbd2e 100644 --- a/frontend-modern/src/components/Settings/__tests__/SettingsPageShell.mobile.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/SettingsPageShell.mobile.test.tsx @@ -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('api'); + const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false); + const [sidebarCollapsed, setSidebarCollapsed] = createSignal(true); + const [searchQuery, setSearchQuery] = createSignal(''); + + render(() => ( + ({ 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} + > +
Panel content
+
+ )); + + 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)); }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 7bd7b6e72..fd3b005de 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -156,11 +156,13 @@ describe('settings architecture guardrails', () => { expect(settingsPageShellSource).toContain( '', ); - 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'); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts index fbfb15cc7..eed8d0eff 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts @@ -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', diff --git a/frontend-modern/src/components/shared/FeatureGateSection.tsx b/frontend-modern/src/components/shared/FeatureGateSection.tsx index fedc46686..dfa473b1e 100644 --- a/frontend-modern/src/components/shared/FeatureGateSection.tsx +++ b/frontend-modern/src/components/shared/FeatureGateSection.tsx @@ -33,18 +33,20 @@ export interface FeatureGateSectionProps { * stay identical across every surface that gates a feature. */ export const FeatureGateSection: Component = (props) => ( -
-
+
+
{props.icon} -
-

{props.title}

-

{props.body}

+
+

{props.title}

+

+ {props.body} +

-
+
{props.upgradeLabel ?? UPGRADE_ACTION_LABEL} diff --git a/frontend-modern/src/components/shared/SettingsPanel.tsx b/frontend-modern/src/components/shared/SettingsPanel.tsx index ed20b2f9a..d1edba2b9 100644 --- a/frontend-modern/src/components/shared/SettingsPanel.tsx +++ b/frontend-modern/src/components/shared/SettingsPanel.tsx @@ -28,32 +28,35 @@ export function SettingsPanel(props: SettingsPanelProps) { return ( -
-
-
+
+
+

{local.title}

-

{local.description}

+

+ {local.description} +

-
{local.action}
+
{local.action}
{local.children}
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 955c57eed..0c686709e 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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', diff --git a/frontend-modern/src/i18n/messages.de.ts b/frontend-modern/src/i18n/messages.de.ts index 9400d6329..81e58871d 100644 --- a/frontend-modern/src/i18n/messages.de.ts +++ b/frontend-modern/src/i18n/messages.de.ts @@ -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', diff --git a/frontend-modern/src/i18n/messages.es.ts b/frontend-modern/src/i18n/messages.es.ts index f53bb9218..c3860256f 100644 --- a/frontend-modern/src/i18n/messages.es.ts +++ b/frontend-modern/src/i18n/messages.es.ts @@ -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', diff --git a/frontend-modern/src/i18n/messages.ts b/frontend-modern/src/i18n/messages.ts index 13c3624e5..6905fa227 100644 --- a/frontend-modern/src/i18n/messages.ts +++ b/frontend-modern/src/i18n/messages.ts @@ -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', diff --git a/frontend-modern/src/index.css b/frontend-modern/src/index.css index 76e860dd7..cf33ad018 100644 --- a/frontend-modern/src/index.css +++ b/frontend-modern/src/index.css @@ -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; diff --git a/frontend-modern/src/utils/__tests__/settingsShellPresentation.test.ts b/frontend-modern/src/utils/__tests__/settingsShellPresentation.test.ts index 1dbc5fb87..30a16b019 100644 --- a/frontend-modern/src/utils/__tests__/settingsShellPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/settingsShellPresentation.test.ts @@ -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', diff --git a/frontend-modern/src/utils/settingsShellPresentation.ts b/frontend-modern/src/utils/settingsShellPresentation.ts index c60d7cbdf..8e2df4d88 100644 --- a/frontend-modern/src/utils/settingsShellPresentation.ts +++ b/frontend-modern/src/utils/settingsShellPresentation.ts @@ -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; diff --git a/tests/integration/tests/15-settings-shell-consistency.spec.ts b/tests/integration/tests/15-settings-shell-consistency.spec.ts index f0d58e91c..a77167bf9 100644 --- a/tests/integration/tests/15-settings-shell-consistency.spec.ts +++ b/tests/integration/tests/15-settings-shell-consistency.spec.ts @@ -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();