diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7b273c92..bdb6cb9f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -97,10 +97,7 @@ "oidc_clients": "OIDC Clients", "api_keys": "API Keys", "application_configuration": "Application Configuration", - "settings": "Settings", - "update_pocket_id": "Update Pocket ID", "sqlite_storage_warning": "We detected that your SQLite database is stored on a network-attached drive like NFS, SMB, or FUSE. This is unsupported and can lead to your database getting corrupted. {#link href=|https://pocket-id.org/docs/configuration/environment-variables#sqlite|}See docs.{/link}", - "powered_by": "Powered by", "see_your_recent_account_activities": "See your account activities within the configured retention period.", "time": "Time", "event": "Event", @@ -714,5 +711,16 @@ "custom_color_note_lightened": "Lightened in dark mode so buttons stand out from the background.", "custom_color_note_edge": "Buttons get a thin border because the color is close to the background.", "custom_color_note_near_status": "This color is close to the error, warning or success color, so buttons may read as a status.", - "custom": "Custom" + "custom": "Custom", + "account": "Account", + "activity": "Activity", + "claim_mappings": "Claim mappings", + "configuration": "Configuration", + "main": "Main", + "update_available": "Update available", + "collapse_sidebar": "Collapse sidebar", + "expand_sidebar": "Expand sidebar", + "appearance": "Appearance", + "account_menu": "Account menu", + "more": "More" } diff --git a/frontend/src/app.css b/frontend/src/app.css index f015cb6f..e0fc0650 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -94,12 +94,11 @@ --info-fg: #0a0a0a; --info-soft: #f5f5f5; --info-border: #e8e8e8; - --well-light: #f6f4f0; - --well-dark: #242220; - --well-light-fg: #57534d; - --well-dark-fg: #bab4aa; + --well-light: #f5f5f5; + --well-dark: #000000; + --well-light-fg: #5e5e5e; + --well-dark-fg: #a1a1a1; --qr-bg: #ffffff; - --qr-fg: #121212; --scrim: rgb(18 17 16 / 0.4); --ring: #0a0a0a; --ring-halo: rgb(10 10 10 / 0.24); @@ -185,12 +184,11 @@ --info-fg: #ededed; --info-soft: #0e0e0e; --info-border: #1f1f1f; - --well-light: #f6f4f0; - --well-dark: #242220; - --well-light-fg: #57534d; - --well-dark-fg: #bab4aa; + --well-light: #f5f5f5; + --well-dark: #000000; + --well-light-fg: #5e5e5e; + --well-dark-fg: #a1a1a1; --qr-bg: #ffffff; - --qr-fg: #121212; --scrim: rgb(0 0 0 / 0.6); --ring: #ededed; --ring-halo: rgb(237 237 237 / 0.24); diff --git a/frontend/src/lib/components/header/header-avatar.svelte b/frontend/src/lib/components/header/header-avatar.svelte deleted file mode 100644 index 95841ab1..00000000 --- a/frontend/src/lib/components/header/header-avatar.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - -
-

- {$userStore?.displayName} -

-

{$userStore?.email}

-
-
- - - goto('/settings/account')} - > {m.my_account()} - {m.logout()} - -
-
diff --git a/frontend/src/lib/components/header/header.svelte b/frontend/src/lib/components/header/header.svelte deleted file mode 100644 index 3d24ba74..00000000 --- a/frontend/src/lib/components/header/header.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - -
-
-
- {#if !isAuthPage} - - - -

- {m.settings()} -

-
- {/if} -
-
- {#if !isAuthPage} - - {/if} - {#if $userStore?.id} - - {/if} -
-
-
diff --git a/frontend/src/lib/components/header/mode-switcher.svelte b/frontend/src/lib/components/header/mode-switcher.svelte deleted file mode 100644 index 1ef3a563..00000000 --- a/frontend/src/lib/components/header/mode-switcher.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - Toggle theme - - - setMode('light')}>{m.light()} - setMode('dark')}>{m.dark()} - resetMode()}>{m.system()} - - diff --git a/frontend/src/lib/components/shell/account-menu.svelte b/frontend/src/lib/components/shell/account-menu.svelte new file mode 100644 index 00000000..23e9d828 --- /dev/null +++ b/frontend/src/lib/components/shell/account-menu.svelte @@ -0,0 +1,153 @@ + + +{#snippet appearanceOptions()} + setModeInstantly(v as 'light' | 'dark' | 'system')} + > + {m.light()} + {m.dark()} + {m.system()} + +{/snippet} + +{#if $userStore} + { + if (o) jumping = false; + }} + > + + + {#snippet child({ props: tooltipProps })} + + {#snippet child({ props })} + + {/snippet} + + {/snippet} + + + {$userStore.displayName}{#if version} + · {version}{/if} + + + + +
+ + + {$userStore.firstName?.[0]} + +
+ {$userStore.displayName} + {$userStore.email} +
+
+ + + { + jumping = true; + goto('/settings/account'); + }} + > + + {m.my_account()} + + {#if variant !== 'avatar'} + + + + + {m.appearance()} + {appearanceLabels[userPrefersMode.current]} + + + + {@render appearanceOptions()} + + + {/if} + + + {#if variant === 'avatar'} + + + {m.appearance()} + {@render appearanceOptions()} + + {/if} + + + + {m.sign_out()} + +
+
+{/if} diff --git a/frontend/src/lib/components/shell/app-sidebar.svelte b/frontend/src/lib/components/shell/app-sidebar.svelte new file mode 100644 index 00000000..34dfeb2a --- /dev/null +++ b/frontend/src/lib/components/shell/app-sidebar.svelte @@ -0,0 +1,247 @@ + + + diff --git a/frontend/src/lib/components/shell/nav-items.ts b/frontend/src/lib/components/shell/nav-items.ts new file mode 100644 index 00000000..1a42a199 --- /dev/null +++ b/frontend/src/lib/components/shell/nav-items.ts @@ -0,0 +1,98 @@ +import { m } from '#lib/paraglide/messages.js'; +import { + Blocks, + Braces, + FileText, + History, + IdCard, + Key, + LayoutGrid, + Server, + SlidersHorizontal, + User, + UsersRound +} from '@lucide/svelte'; +import type { Component } from 'svelte'; + +export type NavItem = { href: string; label: string; icon: Component }; +export type NavGroup = { label: string; items: NavItem[] }; + +// The sidebar, the rail and the phone bar share these destinations +export function navGroups({ + isAdmin, + verificationPending, + showApis, + showApiKeys +}: { + isAdmin: boolean; + verificationPending: boolean; + showApis: boolean; + showApiKeys: boolean; +}): NavGroup[] { + // A user who still has to verify their email can only reach their account until they do + if (verificationPending) { + return [ + { + label: m.account(), + items: [{ href: '/settings/account', label: m.my_account(), icon: IdCard }] + } + ]; + } + + const groups: NavGroup[] = [ + { + label: m.account(), + items: [ + { href: '/settings/account', label: m.my_account(), icon: IdCard }, + { href: '/settings/apps', label: m.my_apps(), icon: LayoutGrid }, + { href: '/settings/audit-log', label: m.activity(), icon: History } + ] + } + ]; + + if (isAdmin) { + groups.push({ + label: m.administration(), + items: [ + { href: '/settings/admin/users', label: m.users(), icon: User }, + { href: '/settings/admin/user-groups', label: m.groups(), icon: UsersRound }, + { href: '/settings/admin/oidc-clients', label: m.oidc_clients(), icon: Blocks }, + { + href: '/settings/admin/oidc-claim-mapping-policies', + label: m.claim_mappings(), + icon: Braces + }, + { href: '/settings/admin/apis', label: m.apis(), icon: Server }, + { href: '/settings/admin/api-keys', label: m.api_keys(), icon: Key }, + { href: '/settings/audit-log/global', label: m.audit_log(), icon: FileText }, + { + href: '/settings/admin/application-configuration', + label: m.configuration(), + icon: SlidersHorizontal + } + ] + }); + } + + return groups; +} + +// APIs and API keys join the sidebar once one exists (docs/design-system.md, PO11), and a failed check keeps the row +export async function checkApiRows() { + const first = { pagination: { page: 1, limit: 1 } }; + const exists = (list: Promise>) => + list.then((r) => r.data.length > 0).catch(() => true); + const [apis, apiKeys] = await Promise.all([ + exists(new ApisService().list(first)), + exists(new ApiKeyService().list(first)) + ]); + return { apis, apiKeys }; +} + +// The most specific destination wins, so the global audit log is not also marked as Activity +export function activeHref(groups: NavGroup[], pathname: string) { + return groups + .flatMap((g) => g.items.map((i) => i.href)) + .filter((href) => pathname === href || pathname.startsWith(href + '/')) + .sort((a, b) => b.length - a.length)[0]; +} diff --git a/frontend/src/lib/components/shell/phone-nav.svelte b/frontend/src/lib/components/shell/phone-nav.svelte new file mode 100644 index 00000000..8c6d8a8b --- /dev/null +++ b/frontend/src/lib/components/shell/phone-nav.svelte @@ -0,0 +1,128 @@ + + +
+ + + {$appConfigStore.appName} + + +
+ + + +{#snippet tab( + href: string, + label: string, + Icon: NavGroup['items'][number]['icon'], + active: boolean +)} + + + {label} + +{/snippet} diff --git a/frontend/src/lib/components/sidebar.svelte b/frontend/src/lib/components/sidebar.svelte deleted file mode 100644 index 65c63917..00000000 --- a/frontend/src/lib/components/sidebar.svelte +++ /dev/null @@ -1,161 +0,0 @@ - - - diff --git a/frontend/src/lib/utils/color-theme-util.ts b/frontend/src/lib/utils/color-theme-util.ts index 21e99663..2e078ce5 100644 --- a/frontend/src/lib/utils/color-theme-util.ts +++ b/frontend/src/lib/utils/color-theme-util.ts @@ -25,6 +25,9 @@ export type ColorTheme = { accent: string; tokens: Record>; accentInputs: Record; + // Mono separates its sidebar with a hairline; Midnight paints a navy sidebar that remaps the neutrals inside it + sidebarEdge?: boolean; + sidebarVars?: Record; }; export const DEFAULT_COLOR_THEME = 'mono'; @@ -406,15 +409,22 @@ export function applyColorTheme(value: string) { Object.entries(vars) .map(([k, v]) => `--${k}: ${v};`) .join(' '); - const css = (['light', 'dark'] as const) - .map((mode) => { - const vars = { - ...theme.tokens[mode], - ...primaryVars(deriveAccent(theme.accent, theme.accentInputs[mode])) - }; - return `${mode === 'light' ? 'html:root' : 'html.dark'} { ${block(vars)} }`; - }) - .join('\n'); + const rules = (['light', 'dark'] as const).map((mode) => { + const vars = { + ...theme.tokens[mode], + ...primaryVars(deriveAccent(theme.accent, theme.accentInputs[mode])) + }; + return `${mode === 'light' ? 'html:root' : 'html.dark'} { ${block(vars)} }`; + }); + if (theme.sidebarEdge) { + rules.push('[data-slot="app-sidebar"] { box-shadow: inset -1px 0 0 var(--border); }'); + } + if (theme.sidebarVars) { + rules.push( + `[data-slot="app-sidebar"], [data-slot="phone-top-bar"] { ${block(theme.sidebarVars)} color: var(--fg); }` + ); + } + const css = rules.join('\n'); let el = document.getElementById('color-theme'); if (!el) { diff --git a/frontend/src/lib/utils/color-themes.ts b/frontend/src/lib/utils/color-themes.ts index 7c7fd904..003824b0 100644 --- a/frontend/src/lib/utils/color-themes.ts +++ b/frontend/src/lib/utils/color-themes.ts @@ -27,13 +27,14 @@ export const colorThemes: ColorTheme[] = [ 'surface-raised': '#ffffff', 'fill-skeleton': '#eeeeee', 'border-control': '#8c8c8c', - 'border-field': '#8c8c8c', - 'on-solid': '#ffffff', 'info-solid': '#5e5e5e', 'info-fg': '#0a0a0a', 'info-soft': '#f5f5f5', 'info-border': '#e8e8e8', - 'qr-fg': '#121212' + 'well-light': '#f5f5f5', + 'well-dark': '#000000', + 'well-light-fg': '#5e5e5e', + 'well-dark-fg': '#a1a1a1' }, dark: { sidebar: '#0a0a0a', @@ -57,13 +58,14 @@ export const colorThemes: ColorTheme[] = [ 'surface-raised': '#121212', 'fill-skeleton': '#1a1a1a', 'border-control': '#6b6b6b', - 'border-field': '#6b6b6b', - 'on-solid': '#0a0a0a', 'info-solid': '#a1a1a1', 'info-fg': '#ededed', 'info-soft': '#0e0e0e', 'info-border': '#1f1f1f', - 'qr-fg': '#121212' + 'well-light': '#f5f5f5', + 'well-dark': '#000000', + 'well-light-fg': '#5e5e5e', + 'well-dark-fg': '#a1a1a1' } }, accentInputs: { @@ -117,7 +119,8 @@ export const colorThemes: ColorTheme[] = [ success: 150 } } - } + }, + sidebarEdge: true }, { id: 'mist', @@ -144,13 +147,14 @@ export const colorThemes: ColorTheme[] = [ 'surface-raised': '#ffffff', 'fill-skeleton': '#eceef2', 'border-control': '#868d99', - 'border-field': '#868d99', - 'on-solid': '#ffffff', 'info-solid': '#545b67', 'info-fg': '#0f1218', 'info-soft': '#f7f8fa', 'info-border': '#e3e6eb', - 'qr-fg': '#121212' + 'well-light': '#f7f8fa', + 'well-dark': '#1b1e23', + 'well-light-fg': '#545b67', + 'well-dark-fg': '#a3aab5' }, dark: { sidebar: '#15171b', @@ -174,13 +178,14 @@ export const colorThemes: ColorTheme[] = [ 'surface-raised': '#23272d', 'fill-skeleton': '#272b31', 'border-control': '#737b87', - 'border-field': '#737b87', - 'on-solid': '#15171b', 'info-solid': '#a3aab5', 'info-fg': '#eef0f3', 'info-soft': '#202328', 'info-border': '#2e333a', - 'qr-fg': '#121212' + 'well-light': '#f7f8fa', + 'well-dark': '#1b1e23', + 'well-light-fg': '#545b67', + 'well-dark-fg': '#a3aab5' } }, accentInputs: { @@ -261,13 +266,14 @@ export const colorThemes: ColorTheme[] = [ 'surface-raised': '#ffffff', 'fill-skeleton': '#e8ede4', 'border-control': '#838c7e', - 'border-field': '#838c7e', - 'on-solid': '#ffffff', 'info-solid': '#515a4e', 'info-fg': '#121611', 'info-soft': '#f6f8f4', 'info-border': '#e0e6db', - 'qr-fg': '#121212' + 'well-light': '#f6f8f4', + 'well-dark': '#1b1f1a', + 'well-light-fg': '#515a4e', + 'well-dark-fg': '#a5ad9e' }, dark: { sidebar: '#151814', @@ -291,13 +297,14 @@ export const colorThemes: ColorTheme[] = [ 'surface-raised': '#242922', 'fill-skeleton': '#272c25', 'border-control': '#747d6f', - 'border-field': '#747d6f', - 'on-solid': '#151814', 'info-solid': '#a5ad9e', 'info-fg': '#eef2eb', 'info-soft': '#20241e', 'info-border': '#2f362c', - 'qr-fg': '#121212' + 'well-light': '#f6f8f4', + 'well-dark': '#1b1f1a', + 'well-light-fg': '#515a4e', + 'well-dark-fg': '#a5ad9e' } }, accentInputs: { @@ -378,13 +385,23 @@ export const colorThemes: ColorTheme[] = [ 'surface-raised': '#ffffff', 'fill-skeleton': '#ebedf2', 'border-control': '#858c9b', - 'border-field': '#858c9b', - 'on-solid': '#ffffff', 'info-solid': '#535a6b', 'info-fg': '#0e1220', 'info-soft': '#f6f7fa', 'info-border': '#e2e5ec', - 'qr-fg': '#121212' + 'well-light': '#f6f7fa', + 'well-dark': '#161a25', + 'well-light-fg': '#535a6b', + 'well-dark-fg': '#a2aabb', + 'sidebar-fg': '#d6dcea', + 'sidebar-muted': '#97a2ba', + 'sidebar-subtle': '#66718a', + 'sidebar-hover': '#1e2540', + 'sidebar-selected': '#28304e', + 'sidebar-border': '#262e48', + 'sidebar-field': '#1e2540', + 'sidebar-edge': '#36405f', + 'sidebar-ring': '#d6dcea' }, dark: { sidebar: '#0d111d', @@ -408,13 +425,23 @@ export const colorThemes: ColorTheme[] = [ 'surface-raised': '#1f2430', 'fill-skeleton': '#232836', 'border-control': '#727a8c', - 'border-field': '#727a8c', - 'on-solid': '#0d111d', 'info-solid': '#a2aabb', 'info-fg': '#edf0f6', 'info-soft': '#1b1f2a', 'info-border': '#2a3040', - 'qr-fg': '#121212' + 'well-light': '#f6f7fa', + 'well-dark': '#161a25', + 'well-light-fg': '#535a6b', + 'well-dark-fg': '#a2aabb', + 'sidebar-fg': '#d6dcea', + 'sidebar-muted': '#97a2ba', + 'sidebar-subtle': '#5f6a82', + 'sidebar-hover': '#161c2e', + 'sidebar-selected': '#212a43', + 'sidebar-border': '#1b2236', + 'sidebar-field': '#141a2b', + 'sidebar-edge': '#2d3652', + 'sidebar-ring': '#d6dcea' } }, accentInputs: { @@ -468,6 +495,20 @@ export const colorThemes: ColorTheme[] = [ success: 150 } } + }, + sidebarVars: { + canvas: 'var(--sidebar)', + surface: 'var(--sidebar-field)', + 'surface-raised': 'var(--sidebar-selected)', + fg: 'var(--sidebar-fg)', + 'fg-muted': 'var(--sidebar-muted)', + 'fg-subtle': 'var(--sidebar-subtle)', + 'fill-hover': 'var(--sidebar-hover)', + 'fill-selected': 'var(--sidebar-selected)', + 'fill-muted': 'var(--sidebar-selected)', + border: 'var(--sidebar-border)', + 'border-strong': 'var(--sidebar-edge)', + ring: 'var(--sidebar-ring)' } }, { @@ -495,13 +536,14 @@ export const colorThemes: ColorTheme[] = [ 'surface-raised': '#ffffff', 'fill-skeleton': '#ebe9f3', 'border-control': '#888498', - 'border-field': '#888498', - 'on-solid': '#ffffff', 'info-solid': '#56526a', 'info-fg': '#14121b', 'info-soft': '#f8f7fb', 'info-border': '#e5e2ee', - 'qr-fg': '#121212' + 'well-light': '#f8f7fb', + 'well-dark': '#1c1a23', + 'well-light-fg': '#56526a', + 'well-dark-fg': '#aaa5ba' }, dark: { sidebar: '#16141c', @@ -525,13 +567,14 @@ export const colorThemes: ColorTheme[] = [ 'surface-raised': '#25222e', 'fill-skeleton': '#292632', 'border-control': '#787387', - 'border-field': '#787387', - 'on-solid': '#16141c', 'info-solid': '#aaa5ba', 'info-fg': '#f1eff6', 'info-soft': '#211e29', 'info-border': '#312d3c', - 'qr-fg': '#121212' + 'well-light': '#f8f7fb', + 'well-dark': '#1c1a23', + 'well-light-fg': '#56526a', + 'well-dark-fg': '#aaa5ba' } }, accentInputs: { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 8f95d6a7..14376967 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,11 +1,12 @@ {#if !appConfig} {:else} -
{@render children()} {/if} { + if (from?.url?.pathname.startsWith('/settings/admin/api')) + recheckedApiRows = await checkApiRows(); + }); + // Only the content region crossfades between pages; the shell never moves (docs/design-system.md, Page transitions) onNavigate((navigation) => { if ( navigation.shallow || !document.startViewTransition || - $appConfigStore.disableAnimations || + document.documentElement.classList.contains('no-motion') || window.matchMedia('(prefers-reduced-motion: reduce)').matches || !navigation.to?.url.pathname.startsWith('/settings/') || navigation.from?.url.pathname === navigation.to.url.pathname @@ -76,28 +78,24 @@ }); -
-
-
-
-
- -
-
- -
- {#if sqliteStorageWarning && ($userStore?.isAdmin || user?.isAdmin)} - + +
+ +
+ +
+ {#if sqliteStorageWarning && isAdmin} + @@ -106,18 +104,7 @@ {/if} {@render children()} -
-
-
-

- {m.powered_by()} - Pocket ID - ({versionInformation.currentVersion}) -

+
-
+ diff --git a/frontend/src/routes/settings/+layout.ts b/frontend/src/routes/settings/+layout.ts index 5e72d1e5..062ca237 100644 --- a/frontend/src/routes/settings/+layout.ts +++ b/frontend/src/routes/settings/+layout.ts @@ -1,3 +1,4 @@ +import { checkApiRows } from '#lib/components/shell/nav-items.ts'; import StorageService from '#lib/services/storage-service.ts'; import VersionService from '#lib/services/version-service.ts'; import WebAuthnService from '#lib/services/webauthn-service.ts'; @@ -5,17 +6,21 @@ import type { AppVersionInformation } from '#lib/types/application-configuration import { redirect } from '@sveltejs/kit'; import type { LayoutLoad } from './$types'; -export const load: LayoutLoad = async ({ url }) => { +export const load: LayoutLoad = async ({ url, parent, untrack }) => { const versionService = new VersionService(); const storageService = new StorageService(); const webauthnService = new WebAuthnService(); const currentVersion = versionService.getCurrentVersion(); - const [newestVersion, sqliteStorageWarning, passkeys] = await Promise.all([ + const [newestVersion, sqliteStorageWarning, passkeys, apiRows] = await Promise.all([ versionService.getNewestVersion().catch(() => null), storageService.getSqliteStorageWarning().catch(() => false), - webauthnService.listCredentials() + webauthnService.listCredentials(), + // A tracked parent() would rerun this load on every navigation, because the root load reads the pathname + untrack(() => parent()).then(({ user }) => + user?.isAdmin ? checkApiRows() : { apis: false, apiKeys: false } + ) ]); // If newestVersion is empty, it means the check is disabled or failed. @@ -39,6 +44,7 @@ export const load: LayoutLoad = async ({ url }) => { return { versionInformation, sqliteStorageWarning, - passkeys + passkeys, + apiRows }; }; diff --git a/tests/specs/navigation.spec.ts b/tests/specs/navigation.spec.ts index 5e1fdf6e..0314fd37 100644 --- a/tests/specs/navigation.spec.ts +++ b/tests/specs/navigation.spec.ts @@ -3,22 +3,22 @@ import { cleanupBackend } from '../utils/cleanup.util'; test.beforeEach(async () => await cleanupBackend()); -test('settings sidebar has an accessible name', async ({ page }) => { +test('main navigation has an accessible name', async ({ page }) => { await page.goto('/settings/account'); - const nav = page.getByRole('navigation', { name: 'Settings' }); + const nav = page.getByRole('navigation', { name: 'Main' }).first(); await expect(nav).toBeVisible(); }); test('keyboard focus stays on sidebar link after navigating', async ({ page }) => { await page.goto('/settings/account'); - const auditLog = page.getByRole('link', { name: 'Audit Log' }); - await auditLog.focus(); + const activity = page.getByRole('link', { name: 'Activity' }).first(); + await activity.focus(); await page.keyboard.press('Enter'); await page.waitForURL('**/settings/audit-log'); - await expect(auditLog).toBeFocused(); + await expect(activity).toBeFocused(); }); test('unsaved changes block navigation until they are discarded', async ({ page }) => { @@ -29,7 +29,7 @@ test('unsaved changes block navigation until they are discarded', async ({ page await displayName.fill('Pending navigation'); await expect(page.getByText('You have unsaved changes', { exact: true })).toBeVisible(); - const myApps = page.getByRole('link', { name: 'My Apps' }); + const myApps = page.getByRole('link', { name: 'My Apps' }).first(); await myApps.click(); await expect(page).toHaveURL(/\/settings\/account$/);