mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Polish mobile settings experience
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+4
-1
@@ -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();
|
||||
|
||||
+63
-1
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user