Files
data-entry-app/frontend/src/lib/components/AppShell.svelte
T

684 lines
22 KiB
Svelte
Raw Normal View History

2026-06-15 11:59:04 +12:00
<script lang="ts">
import { api } from '$lib/api';
import AuthGate from '$lib/components/AuthGate.svelte';
import WorkspaceBootCard from '$lib/components/app-shell/WorkspaceBootCard.svelte';
import WorkspaceSignedOutCard from '$lib/components/app-shell/WorkspaceSignedOutCard.svelte';
import WorkspaceTabletNav from '$lib/components/app-shell/WorkspaceTabletNav.svelte';
2026-06-16 14:43:17 +12:00
import WorkspaceAppsFab, { type WorkspaceFabItem } from '$lib/components/WorkspaceAppsFab.svelte';
2026-06-15 11:59:04 +12:00
import { PALETTE_RESULT_LIMIT, buildSessionKey, filterSearchItems } from '$lib/components/app-shell/utils';
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
2026-06-16 14:43:17 +12:00
import WorkspacePageHeader from '$lib/components/navigation/WorkspacePageHeader.svelte';
2026-06-15 11:59:04 +12:00
import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
import { currentChangelog } from '$lib/changelog';
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
import { featureFlags } from '$lib/features';
import {
canCreateMixSession as sessionCanCreateMixSession,
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
2026-06-16 14:43:17 +12:00
canOpenClientAccess as sessionCanOpenClientAccess,
2026-06-15 11:59:04 +12:00
canOpenDashboard as sessionCanOpenDashboard,
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster,
canOpenCustomerOrdering as sessionCanOpenCustomerOrdering,
canManageOrdering as sessionCanManageOrdering,
canOpenProductCosting as sessionCanOpenProductCosting,
canOpenReporting as sessionCanOpenReporting,
canOpenSettings as sessionCanOpenSettings,
canOpenThroughput as sessionCanOpenThroughput,
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
getWorkspaceRole,
getWorkspaceHomeHref as sessionWorkspaceHomeHref,
isWorkspaceRouteAllowed
} from '$lib/workspace-access';
import {
2026-06-16 14:43:17 +12:00
accessControlItem,
2026-06-15 11:59:04 +12:00
baseSearchItems,
buildClientNavEntries,
dashboardItem,
editorItem,
ingredientsEditorItem,
footerLinks,
mixCalculatorItem,
orderingItem,
orderingManageChildren,
orderingManageGroup,
2026-06-16 14:43:17 +12:00
pageMeta,
2026-06-15 11:59:04 +12:00
productCostingItem,
reportingItem,
throughputItem,
type FooterLink,
type NavEntry,
type SearchItem,
type NavItem,
workingDocumentItems
} from '$lib/navigation/client-navigation';
import { onMount } from 'svelte';
import packageInfo from '../../../package.json';
let { children } = $props();
const isRootRoute = $derived(page.url.pathname === '/');
2026-06-16 14:43:17 +12:00
let searchOpen = $state(false);
let searchQuery = $state('');
let searchFocusRequest = $state(0);
let appsFabOpen = $state(false);
let sidebarOpen = $state(true);
2026-06-15 11:59:04 +12:00
let userMenuOpen = $state(false);
let navOpen = $state(false);
let showBottomNav = $state(false);
let whatsNewOpen = $state(false);
// The user identity we've already run the "what's new" check for this mount,
// so the dialog is evaluated once per login rather than on every navigation.
let whatsNewCheckedFor = $state<string | null>(null);
let isRestoringSession = $state(false);
let restoredSessionKey = $state<string | null>(null);
let seededSearchItems = $state<SearchItem[]>([]);
let seededSearchKey = $state<string | null>(null);
let bootDelayDone = $state(false);
2026-06-16 14:43:17 +12:00
let sidebarStateReady = $state(false);
const SIDEBAR_STORAGE_KEY = 'hsf:shell:sidebar-open';
2026-06-15 11:59:04 +12:00
const appVersion = `v${packageInfo.version}`;
const currentYear = new Date().getFullYear();
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession));
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
2026-06-16 14:43:17 +12:00
const shellPageMeta = $derived(
routeGuardPending
? { title: 'Loading Workspace', category: 'Workspace', icon: dashboardItem.icon }
: pageMeta(page.url.pathname)
2026-06-15 11:59:04 +12:00
);
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
const visibleWorkingDocumentItems = $derived(
!$clientSession
? workingDocumentItems
: workingDocumentItems.filter((item) => {
if (item.href === '/mixes') return canOpenMixMaster;
return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
})
);
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
// Ordering serves two audiences: internal staff get the management console
// (/ordering/manage), customers get the catalogue (/ordering).
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
// Internal staff get the collapsible "Order Management" family (queue +
// products/customers/pricing/settings/integrations); customers get the single
// catalogue link. Either way it becomes one NavEntry the rail can render.
const visibleOrderingEntry = $derived<NavEntry | null>(
canManageOrdering
? { kind: 'group', group: orderingManageGroup }
: canOpenCustomerOrdering
? { kind: 'item', item: orderingItem }
: null
);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
2026-06-16 14:43:17 +12:00
const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Operations" family (mix
// calculator plus throughput), a "Costing" family (product costing and the
// editors), then the standalone ordering/insights modules. Built from the same
// access-filtered items, so a role only ever sees the families it may open.
2026-06-15 11:59:04 +12:00
const navEntries = $derived(
buildClientNavEntries({
dashboard: visibleDashboardItem,
operations: [
2026-06-15 11:59:04 +12:00
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleThroughputItem ? [visibleThroughputItem] : [])
],
costing: [
2026-06-15 11:59:04 +12:00
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
...visibleWorkingDocumentItems
],
ordering: visibleOrderingEntry,
reporting: visibleReportingItem
})
);
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([
...(!isOperationsUser ? footerLinks : [])
] as FooterLink[]);
2026-06-16 14:43:17 +12:00
const fabItems = $derived.by(() => {
const items = [
visibleDashboardItem,
...visibleWorkingDocumentItems,
visibleMixCalculatorItem,
visibleProductCostingItem,
visibleThroughputItem,
visibleOrderingEntry?.kind === 'item'
? visibleOrderingEntry.item
: visibleOrderingEntry?.group
? {
href: visibleOrderingEntry.group.href ?? '/ordering/manage',
label: visibleOrderingEntry.group.label,
icon: visibleOrderingEntry.group.icon
}
: null,
visibleReportingItem,
visibleEditorItem,
visibleIngredientsEditorItem,
visibleAccessControlItem
].filter((item): item is { href: string; label: string; icon: WorkspaceFabItem['icon'] } => Boolean(item));
const seen = new Set<string>();
return items.flatMap((item) => {
if (seen.has(item.href)) {
return [];
}
seen.add(item.href);
return [{ href: item.href, label: item.label, icon: item.icon } satisfies WorkspaceFabItem];
});
});
2026-06-15 11:59:04 +12:00
const primaryBottomNavigation = $derived(
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...visibleWorkingDocumentItems.slice(0, 2)
]
);
const workingDocumentsActive = $derived(
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
);
const visibleBaseSearchItems = $derived(
baseSearchItems.filter((item) => {
if (item.href === '/') return canOpenDashboard;
if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/mixes/new') return canCreateMixWorksheet;
if (item.href === '/mix-calculator') return canOpenMixCalculator;
if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession);
if (item.href === '/editor') return canOpenEditor;
if (item.href === '/ingredients') return canOpenEditor;
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings;
return true;
})
);
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
const showWorkspaceBoot = $derived(!isRootRoute && (!$sessionHydrated || !bootDelayDone));
2026-06-16 14:43:17 +12:00
const showDesktopSidebar = $derived(!showBottomNav);
2026-06-15 11:59:04 +12:00
2026-06-16 14:43:17 +12:00
function restoreSidebarState() {
if (typeof window === 'undefined') {
return true;
}
try {
return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) !== 'false';
} catch {
return true;
}
}
function persistSidebarState() {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarOpen));
} catch {
// Storage failures should not block shell interactions.
}
}
function openSearch(query = '') {
searchQuery = query;
searchOpen = true;
searchFocusRequest += 1;
appsFabOpen = false;
2026-06-15 11:59:04 +12:00
userMenuOpen = false;
navOpen = false;
}
function syncViewport() {
showBottomNav = window.innerWidth <= 1180;
if (!showBottomNav) {
navOpen = false;
}
}
async function runSearchItem(item: SearchItem) {
2026-06-16 14:43:17 +12:00
searchOpen = false;
searchQuery = '';
2026-06-15 11:59:04 +12:00
await goto(item.href);
}
async function openSettings() {
2026-06-16 14:43:17 +12:00
appsFabOpen = false;
2026-06-15 11:59:04 +12:00
userMenuOpen = false;
navOpen = false;
await goto('/settings');
}
async function signOut() {
try {
if ($clientSession?.role === 'internal') {
await api.internalLogout();
} else {
await api.clientLogout();
}
} catch {
// Clearing the local session remains the safe fallback.
} finally {
clientSession.clear();
}
}
2026-06-16 14:43:17 +12:00
const searchState = $derived(filterSearchItems(searchItems, searchQuery, PALETTE_RESULT_LIMIT));
2026-06-15 11:59:04 +12:00
$effect(() => {
page.url.pathname;
2026-06-16 14:43:17 +12:00
appsFabOpen = false;
2026-06-15 11:59:04 +12:00
userMenuOpen = false;
2026-06-16 14:43:17 +12:00
searchOpen = false;
searchQuery = '';
2026-06-15 11:59:04 +12:00
navOpen = false;
});
2026-06-16 14:43:17 +12:00
$effect(() => {
if (!sidebarStateReady) {
return;
}
sidebarOpen;
persistSidebarState();
});
2026-06-15 11:59:04 +12:00
$effect(() => {
const hydrated = $sessionHydrated;
const sessionKey = buildSessionKey($clientSession);
if (!hydrated) {
return;
}
if (!sessionKey) {
isRestoringSession = false;
restoredSessionKey = null;
return;
}
if (restoredSessionKey === sessionKey) {
return;
}
restoredSessionKey = sessionKey;
isRestoringSession = true;
// Internal Hunter Stock Feeds users are refreshed against /api/access/me;
// legacy client-portal users keep using /api/auth/client/session.
const refresh = $clientSession?.role === 'internal' ? api.internalSession() : api.clientSession();
refresh
.then((session) => {
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
clientSession.set(session);
return invalidateAll();
})
.catch(() => {
restoredSessionKey = null;
clientSession.clear();
})
.finally(() => {
isRestoringSession = false;
});
});
2026-06-16 14:43:17 +12:00
// Search items are seeded lazily — three list endpoints worth of
// data only when the user actually opens the search, not on every login or
2026-06-15 11:59:04 +12:00
// navigation. Subsequent opens hit the api.ts cache.
$effect(() => {
const hydrated = $sessionHydrated;
const session = $clientSession;
const sessionKey = buildSessionKey(session);
2026-06-16 14:43:17 +12:00
const shouldSeed = searchOpen;
2026-06-15 11:59:04 +12:00
if (!hydrated || !session || !sessionKey) {
seededSearchItems = [];
seededSearchKey = null;
return;
}
if (!shouldSeed || seededSearchKey === sessionKey) {
return;
}
seededSearchKey = sessionKey;
Promise.all([
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
? api.mixCalculatorSessions()
: Promise.resolve([])
])
.then(([mixes, sessions]) => {
if (seededSearchKey !== sessionKey) {
return;
}
seededSearchItems = [
...mixes.map((mix) => ({
href: `/mixes/${mix.id}`,
label: mix.name,
description: `Mix · ${mix.client_name} · ${mix.total_mix_kg}kg`,
keywords: `mix ${mix.name} ${mix.client_name} ${mix.notes ?? ''} ${mix.ingredients.map((ingredient) => ingredient.raw_material_name).join(' ')}`
})),
...sessions.map((savedSession) => ({
href: `/mix-calculator/${savedSession.id}`,
label: `${savedSession.session_number} · ${savedSession.product_name}`,
description: `Mix Session · ${savedSession.prepared_by_name} · ${savedSession.mix_date}`,
keywords: `mix calculator session ${savedSession.session_number} ${savedSession.product_name} ${savedSession.mix_name} ${savedSession.client_name} ${savedSession.prepared_by_name} ${savedSession.notes ?? ''}`
}))
];
})
.catch(() => {
if (seededSearchKey === sessionKey) {
seededSearchItems = [];
}
});
});
$effect(() => {
if ($sessionHydrated && !$clientSession && !isRootRoute) {
goto('/', { replaceState: true });
}
});
$effect(() => {
if (!$sessionHydrated || !$clientSession) {
return;
}
if (currentRouteAllowed || page.url.pathname === workspaceHomeHref) {
return;
}
goto(workspaceHomeHref, { replaceState: true });
});
// Surface the release notes once per version per user, right after login.
// hasSeenVersion keeps this to a single appearance: once dismissed (which
// records the version), it won't return until the next version ships.
$effect(() => {
if (!$sessionHydrated || !$clientSession || !currentChangelog) {
return;
}
const userKey = buildSessionKey($clientSession);
if (!userKey) {
return;
}
if (whatsNewCheckedFor === userKey) {
return;
}
whatsNewCheckedFor = userKey;
if (!hasSeenVersion(userKey, currentChangelog.version)) {
whatsNewOpen = true;
}
});
function dismissWhatsNew() {
const userKey = buildSessionKey($clientSession);
if (userKey && currentChangelog) {
markVersionSeen(userKey, currentChangelog.version);
}
whatsNewOpen = false;
}
onMount(() => {
2026-06-16 14:43:17 +12:00
sidebarOpen = restoreSidebarState();
sidebarStateReady = true;
2026-06-15 11:59:04 +12:00
const bootTimer = window.setTimeout(() => {
bootDelayDone = true;
}, 1500);
syncViewport();
const handleKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const isTypingField =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target?.isContentEditable;
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
event.preventDefault();
2026-06-16 14:43:17 +12:00
openSearch();
2026-06-15 11:59:04 +12:00
}
if (event.key === 'Escape') {
2026-06-16 14:43:17 +12:00
searchOpen = false;
appsFabOpen = false;
2026-06-15 11:59:04 +12:00
userMenuOpen = false;
navOpen = false;
}
};
window.addEventListener('keydown', handleKeydown);
window.addEventListener('resize', syncViewport);
return () => {
window.clearTimeout(bootTimer);
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('resize', syncViewport);
};
});
const userInitials = $derived(
($clientSession?.name ?? '')
.split(' ')
.slice(0, 2)
.map((w: string) => w[0])
.join('')
.toUpperCase() || '?'
);
</script>
<svelte:head>
2026-06-16 14:43:17 +12:00
<title>{shellPageMeta.title} | Hunter Premium Produce</title>
2026-06-15 11:59:04 +12:00
</svelte:head>
{#if !$clientSession}
<div class="signed-out-shell">
{#if isRootRoute}
{@render children()}
{:else if showWorkspaceBoot}
<WorkspaceBootCard />
{:else}
<WorkspaceSignedOutCard />
{/if}
</div>
{:else}
2026-06-16 14:43:17 +12:00
<div class:sidebar-collapsed={!sidebarOpen && showDesktopSidebar} class="app-shell">
{#if showDesktopSidebar}
2026-06-15 11:59:04 +12:00
<ClientPrimaryRail
2026-06-16 14:43:17 +12:00
collapsed={!sidebarOpen}
2026-06-15 11:59:04 +12:00
currentPath={shellPathname}
entries={navEntries}
brandHref={workspaceHomeHref}
footerItems={visibleFooterLinks}
{appVersion}
{currentYear}
{canOpenSettings}
onOpenSettings={openSettings}
onSignOut={signOut}
/>
{/if}
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
<ClientTopbar
sessionHydrated={$sessionHydrated}
session={$clientSession}
2026-06-16 14:43:17 +12:00
showSidebarToggle={!showBottomNav}
{sidebarOpen}
2026-06-15 11:59:04 +12:00
{userInitials}
{userMenuOpen}
{canUseWorkspaceSearch}
2026-06-16 14:43:17 +12:00
bind:searchQuery={searchQuery}
bind:searchOpen={searchOpen}
searchFocusRequest={searchFocusRequest}
filteredSearchItems={searchState.filteredItems}
hiddenResultCount={searchState.hiddenResultCount}
2026-06-15 11:59:04 +12:00
{canOpenSettings}
2026-06-16 14:43:17 +12:00
onRunSearchItem={runSearchItem}
onToggleSidebar={() => (sidebarOpen = !sidebarOpen)}
2026-06-15 11:59:04 +12:00
onToggleUserMenu={() => {
userMenuOpen = !userMenuOpen;
2026-06-16 14:43:17 +12:00
appsFabOpen = false;
2026-06-15 11:59:04 +12:00
}}
onOpenSettings={openSettings}
onSignOut={signOut}
onShowWhatsNew={() => (whatsNewOpen = true)}
/>
<main class="content">
2026-06-16 14:43:17 +12:00
{#if !routeGuardPending}
<WorkspacePageHeader
category={shellPageMeta.category}
title={shellPageMeta.title}
icon={shellPageMeta.icon}
/>
{/if}
2026-06-15 11:59:04 +12:00
<AuthGate
blocked={routeGuardPending}
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
title={isRestoringSession ? 'Restoring your client workspace.' : 'Routing you to an authorised page.'}
detail={
isRestoringSession
? 'Refreshing the saved session before rendering workspace content.'
: `The ${workspaceRole} role cannot open this route, so the workspace is redirecting before any page content mounts.`
}
>
{@render children()}
</AuthGate>
</main>
</div>
2026-06-16 14:43:17 +12:00
<WorkspaceAppsFab bind:open={appsFabOpen} items={fabItems} />
2026-06-15 11:59:04 +12:00
</div>
<WorkspaceTabletNav
bind:navOpen
{showBottomNav}
{primaryBottomNavigation}
{visibleDashboardItem}
{visibleMixCalculatorItem}
{visibleProductCostingItem}
{visibleThroughputItem}
{visibleEditorItem}
{visibleReportingItem}
{visibleWorkingDocumentItems}
{visibleFooterLinks}
{orderingManageGroup}
{orderingManageChildren}
{orderingItem}
{canManageOrdering}
{canOpenCustomerOrdering}
{canCreateMixWorksheet}
{canCreateMixSession}
{canOpenSettings}
pagePath={page.url.pathname}
onOpenSettings={openSettings}
onSignOut={signOut}
/>
{/if}
{#if $clientSession && whatsNewOpen && currentChangelog}
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
{/if}
<style>
.app-shell {
display: grid;
grid-template-columns: 252px minmax(0, 1fr);
min-height: 100vh;
background: var(--color-bg-app);
}
2026-06-16 14:43:17 +12:00
.app-shell.sidebar-collapsed {
grid-template-columns: 4.5rem minmax(0, 1fr);
}
2026-06-15 11:59:04 +12:00
.signed-out-shell {
min-height: 100vh;
padding: 1.5rem;
}
.main-shell {
min-width: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
overflow: hidden;
background: var(--color-bg-app);
}
.content {
--content-padding: 1.34rem;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
padding: var(--content-padding);
overflow: auto;
background: var(--color-bg-app);
}
.main-shell.bottom-nav-layout .content {
padding-bottom: 7.25rem;
}
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 1fr;
}
.content {
--content-padding: 1rem;
padding: 1rem;
}
}
@media (min-width: 1181px) {
.bottom-nav-layout .content {
padding-bottom: 1.34rem;
}
}
@media (max-width: 720px) {
.content {
--content-padding: 0.92rem;
padding: 0.92rem;
}
}
</style>