managarten/packages/shared-ui/src/navigation/PillNavigation.svelte
Till JS 4d6e6e61b4 feat(mana-web): keyboard shortcuts for workbench + nav bars
- 1–9 scroll to the Nth open app on the workbench homepage; 0 opens the
  app picker.
- q/w/e toggle the bottom bars (workbench tabs / search / tags); r opens
  the user-menu PillDropdownBar (expanding the PillNav first if needed);
  t toggles the PillNav visibility.

Adds a `data-user-menu-trigger` hook on the user pill so the layout can
drive the menu bar programmatically without duplicating its config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:53:42 +02:00

1203 lines
31 KiB
Svelte

<script lang="ts">
import type { Snippet } from 'svelte';
import type {
PillNavItem,
PillDropdownItem,
PillNavElement,
PillTabGroupConfig,
PillTagSelectorConfig,
PillAppItem,
PillBarConfig,
SpotlightAction,
ContentSearcher,
} from './types';
import PillDropdown from './PillDropdown.svelte';
import PillTabGroup from './PillTabGroup.svelte';
import PillTagSelector from './PillTagSelector.svelte';
import AppDrawer from './AppDrawer.svelte';
import UserMenuPanel from './UserMenuPanel.svelte';
import GlobalSpotlight from './GlobalSpotlight.svelte';
import { createGlobalSpotlightState } from './useGlobalSpotlight.svelte';
// Phosphor Icons (via shared-icons)
import {
Archive,
Bell,
Buildings,
CalendarBlank,
CaretDown,
CaretLeft,
CaretRight,
CaretUp,
ChartBar,
ChatCircle,
Check,
CheckCircle,
CheckSquare,
Clock,
Cloud,
Columns,
Compass,
CreditCard,
File,
FileText,
Fire,
Folder,
Funnel,
Gear,
Gift,
Globe,
GridFour,
Heart,
House,
Key,
List,
MagnifyingGlass,
Microphone,
Moon,
MusicNote,
MusicNotes,
Palette,
Playlist,
Plus,
Question,
Robot,
Scales,
ShareFat,
ShareNetwork,
Shield,
SignOut,
Sparkle,
Spiral,
Cards,
Sun,
Tag,
Target,
Timer,
Trash,
Tray,
Upload,
User,
Users,
Waveform,
} from '@mana/shared-icons';
// Map icon names to Phosphor components
const phosphorIcons: Record<string, any> = {
home: House,
users: Users,
user: User,
tag: Tag,
heart: Heart,
settings: Gear,
chat: ChatCircle,
'help-circle': Question,
'share-2': ShareNetwork,
bell: Bell,
clock: Clock,
timer: Timer,
target: Target,
globe: Globe,
inbox: Tray,
check: Check,
checkCircle: CheckCircle,
'check-square': CheckSquare,
plus: Plus,
columns: Columns,
kanban: Columns,
tabs: Cards,
mic: Microphone,
calendar: CalendarBlank,
folder: Folder,
archive: Archive,
upload: Upload,
music: MusicNote,
document: File,
chart: ChartBar,
'bar-chart-3': ChartBar,
search: MagnifyingGlass,
list: List,
compass: Compass,
moon: Moon,
sun: Sun,
logout: SignOut,
chevronDown: CaretDown,
chevronUp: CaretUp,
chevronLeft: CaretLeft,
menu: List,
fire: Fire,
grid: GridFour,
gridSmall: GridFour,
palette: Palette,
creditCard: CreditCard,
building: Buildings,
scale: Scales,
robot: Robot,
key: Key,
shield: Shield,
gift: Gift,
'music-notes': MusicNotes,
playlist: Playlist,
waveform: Waveform,
'file-text': FileText,
sparkle: Sparkle,
sparkles: Sparkle,
spiral: Spiral,
share: ShareFat,
trash: Trash,
filter: Funnel,
cloud: Cloud,
};
// Convert app items to dropdown items (will be computed as derived)
function createAppDropdownItems(
apps: PillAppItem[],
allAppsUrl?: string,
allAppsText?: string,
openInPanelHandler?: (appId: string, url: string) => void
): PillDropdownItem[] {
const items: PillDropdownItem[] = apps.map((app) => ({
id: app.id,
label: app.name,
// Use image icon if available, otherwise use grid as fallback
imageUrl: app.icon,
icon: app.icon ? undefined : 'grid',
onClick: (event?: MouseEvent) => {
// Check for modifier keys (Ctrl/Cmd + Click opens in panel)
if (
event &&
(event.ctrlKey || event.metaKey) &&
openInPanelHandler &&
app.url &&
!app.isCurrent
) {
openInPanelHandler(app.id, app.url);
return;
}
if (app.isCurrent) {
// Navigate to home route for current app
window.location.href = '/';
} else if (app.url) {
// Internal paths (same-origin) navigate directly, external URLs open in new tab
const isInternal =
app.url.startsWith('/') ||
new URL(app.url, window.location.origin).origin === window.location.origin;
if (isInternal) {
window.location.href = app.url;
} else {
window.open(app.url, '_blank', 'noopener,noreferrer');
}
}
},
active: app.isCurrent,
disabled: false,
// Show split button if handler is provided and app is not current
showSplitButton: !!openInPanelHandler && !app.isCurrent && !!app.url,
onSplitClick:
openInPanelHandler && app.url ? () => openInPanelHandler(app.id, app.url!) : undefined,
}));
// Add "All Apps" link at the end if href is provided
if (allAppsUrl) {
items.push(
{ id: 'all-apps-divider', label: '', divider: true },
{
id: 'all-apps',
label: allAppsText || 'Alle Apps',
icon: 'grid',
onClick: () => {
window.location.href = allAppsUrl;
},
active: false,
}
);
}
return items;
}
interface Props {
/** Navigation items */
items: PillNavItem[];
/** Current active path */
currentPath?: string;
/** Logo snippet */
logo?: Snippet;
/** App name */
appName?: string;
/** Home/default route */
homeRoute?: string;
/** Called when logout is clicked */
onLogout?: () => void;
/** Called when theme toggle is clicked */
onToggleTheme?: () => void;
/** Whether dark mode is active */
isDark?: boolean;
/** Use 'static' when inside a flex container (bottom-stack pattern). Default: 'fixed'. */
positioning?: 'fixed' | 'static';
/** Whether navigation is collapsed */
isCollapsed?: boolean;
/** Called when collapsed state changes */
onCollapsedChange?: (isCollapsed: boolean) => void;
/** Language dropdown items */
languageItems?: PillDropdownItem[];
/** Current language label */
currentLanguageLabel?: string;
/** Show language switcher */
showLanguageSwitcher?: boolean;
/** Show theme toggle (standalone button, hidden if showThemeVariants is true) */
showThemeToggle?: boolean;
/** Show AI tier selector dropdown */
showAiTierSelector?: boolean;
/** AI tier dropdown items (each representing a toggleable tier) */
aiTierItems?: PillDropdownItem[];
/** Current AI tier label, e.g. "Browser" or "Server" */
currentAiTierLabel?: string;
/** Current AI tier icon name (passed to the dropdown trigger) */
currentAiTierIcon?: string;
/** Show sync status dropdown */
showSyncStatus?: boolean;
/** Sync status dropdown items */
syncStatusItems?: PillDropdownItem[];
/** Current sync status label */
currentSyncLabel?: string;
/** Primary color for active state (CSS custom property or hex) */
primaryColor?: string;
/** Elements to prepend before nav items (tab groups, dividers, nav items) */
prependElements?: PillNavElement[];
/** Additional elements (tab groups, dividers) to show after nav items */
elements?: PillNavElement[];
/** Show logout button */
showLogout?: boolean;
/** Theme variant dropdown items */
themeVariantItems?: PillDropdownItem[];
/** Current theme variant label */
currentThemeVariantLabel?: string;
/** Show theme variant selector */
showThemeVariants?: boolean;
/** Current theme mode ('light', 'dark', 'system') */
themeMode?: 'light' | 'dark' | 'system';
/** Called when theme mode changes */
onThemeModeChange?: (mode: 'light' | 'dark' | 'system') => void;
/** App items for app switcher dropdown */
appItems?: PillAppItem[];
/** Show app switcher dropdown */
showAppSwitcher?: boolean;
/** User email for user dropdown */
userEmail?: string;
/** Settings page href */
settingsHref?: string;
/** Mana/subscription page href */
manaHref?: string;
/** Profile page href */
profileHref?: string;
/** Login page href (shown when not logged in) */
loginHref?: string;
/** All Apps page href */
allAppsHref?: string;
/** All Apps label (default: "Alle Apps") */
allAppsLabel?: string;
// A11y Settings
/** A11y contrast level */
a11yContrast?: 'normal' | 'high';
/** Called when a11y contrast changes */
onA11yContrastChange?: (contrast: 'normal' | 'high') => void;
/** A11y reduce motion setting */
a11yReduceMotion?: boolean;
/** Called when a11y reduce motion changes */
onA11yReduceMotionChange?: (reduce: boolean) => void;
/** Show a11y quick toggles in theme dropdown */
showA11yQuickToggles?: boolean;
/** Called when an app should be opened in a split panel */
onOpenInPanel?: (appId: string, url: string) => void;
/** Quick actions for Cmd+K spotlight (pass to enable spotlight) */
spotlightActions?: SpotlightAction[];
/** Placeholder text for spotlight search */
spotlightPlaceholder?: string;
/** Content searcher for cross-app search in spotlight */
contentSearcher?: ContentSearcher;
/** Accessible label for the nav element */
ariaLabel?: string;
/** Feedback page href (shown in user dropdown). Set to empty string to hide. */
feedbackHref?: string;
/** Themes page href (shown in user dropdown). Set to empty string to hide. */
themesHref?: string;
/** Spiral page href (shown in user dropdown). Set to empty string to hide. */
spiralHref?: string;
/** Credits page href (shown in user dropdown). Set to empty string to hide. */
creditsHref?: string;
/** Trigger label for the user dropdown when no one is signed in. */
guestMenuLabel?: string;
/** Help page href (shown in user dropdown). Set to empty string to hide. */
helpHref?: string;
/** Bottom offset from viewport bottom (default: '0px'). Use to position above other fixed bars. */
bottomOffset?: string;
/** When provided, dropdown triggers (theme, AI tier, sync, user menu) render
* as plain pills that call this callback with a bar config instead of
* opening their in-place PillDropdown popover. The host is expected to
* render the returned items in its own bar (e.g. bottom-stack). Pass null
* to request closing the active bar. */
onOpenBar?: (config: PillBarConfig | null) => void;
/** Id of the bar currently open in the host. Used to highlight the trigger pill. */
activeBarId?: string | null;
}
let {
items,
currentPath = '',
logo,
appName = 'App',
homeRoute = '/',
onLogout,
onToggleTheme,
isDark = false,
positioning = 'fixed',
isCollapsed: externalCollapsed,
onCollapsedChange,
languageItems = [],
currentLanguageLabel = 'Language',
showLanguageSwitcher = false,
showThemeToggle = true,
primaryColor,
prependElements = [],
elements = [],
showLogout = true,
themeVariantItems = [],
currentThemeVariantLabel = 'Theme',
showThemeVariants = false,
showAiTierSelector = false,
aiTierItems = [],
currentAiTierLabel = 'KI',
currentAiTierIcon = 'cpu',
showSyncStatus = false,
syncStatusItems = [],
currentSyncLabel = 'Sync',
themeMode = 'system',
onThemeModeChange,
appItems = [],
showAppSwitcher = false,
userEmail,
settingsHref = '/settings',
manaHref,
profileHref,
loginHref,
allAppsHref,
allAppsLabel = 'Alle Apps',
a11yContrast = 'normal',
onA11yContrastChange,
a11yReduceMotion = false,
onA11yReduceMotionChange,
showA11yQuickToggles = false,
onOpenInPanel,
spotlightActions,
spotlightPlaceholder,
contentSearcher,
ariaLabel,
feedbackHref = '/feedback',
themesHref,
spiralHref,
creditsHref,
guestMenuLabel = 'Menü',
helpHref,
bottomOffset = '0px',
onOpenBar,
activeBarId = null,
}: Props = $props();
// Whether this nav should surface dropdowns as bars instead of popovers.
const barMode = $derived(!!onOpenBar);
// Build the flat PillDropdownItem list for each bar, matching what the
// equivalent PillDropdown would render. Mode toggles + variants + a11y
function toggleBar(config: PillBarConfig) {
if (!onOpenBar) return;
if (activeBarId === config.id) {
onOpenBar(null);
} else {
onOpenBar(config);
}
}
// Type guards for elements
function isTabGroup(element: PillNavElement): element is PillTabGroupConfig {
return 'type' in element && element.type === 'tabs';
}
function isDivider(element: PillNavElement): element is { type: 'divider' } {
return 'type' in element && element.type === 'divider';
}
function isTagSelector(element: PillNavElement): element is PillTagSelectorConfig {
return 'type' in element && element.type === 'tag-selector';
}
function isNavItem(element: PillNavElement): element is PillNavItem {
return 'href' in element;
}
// Truncate email for display (show first part before @, max 12 chars)
function truncateEmail(email: string, maxLength = 12): string {
const atIndex = email.indexOf('@');
const localPart = atIndex > 0 ? email.substring(0, atIndex) : email;
if (localPart.length <= maxLength) {
return localPart;
}
return localPart.substring(0, maxLength) + '…';
}
// Dropdown direction: always up since nav is always at bottom
const dropdownDirection = 'up' as const;
// App drawer state
let appDrawerOpen = $state(false);
// User menu panel state
let userMenuOpen = $state(false);
let userMenuTrigger = $state<HTMLButtonElement | undefined>(undefined);
// Close user menu on navigation
$effect(() => {
currentPath;
userMenuOpen = false;
});
// Account links for UserMenuPanel
const accountLinks = $derived.by(() => {
const links: { id: string; label: string; icon: string; href: string; active?: boolean }[] = [];
if (userEmail && profileHref) {
links.push({
id: 'profile',
label: 'Profil',
icon: 'user',
href: profileHref,
active: currentPath === profileHref,
});
}
links.push({
id: 'settings',
label: 'Einstellungen',
icon: 'settings',
href: settingsHref,
active: currentPath === settingsHref,
});
if (userEmail && manaHref) {
links.push({
id: 'mana',
label: 'Mana',
icon: 'sparkle',
href: manaHref,
active: currentPath === manaHref,
});
}
if (spiralHref) {
links.push({
id: 'spiral',
label: 'Spiral',
icon: 'spiral',
href: spiralHref,
active: currentPath === spiralHref,
});
}
if (creditsHref) {
links.push({
id: 'credits',
label: 'Credits',
icon: 'creditCard',
href: creditsHref,
active: currentPath === creditsHref,
});
}
if (userEmail && feedbackHref) {
links.push({
id: 'feedback',
label: 'Feedback',
icon: 'chat',
href: feedbackHref,
active: currentPath === feedbackHref,
});
}
if (helpHref) {
links.push({
id: 'help',
label: 'Hilfe',
icon: 'help',
href: helpHref,
active: currentPath === helpHref,
});
}
if (themesHref) {
links.push({
id: 'themes',
label: 'Themes',
icon: 'palette',
href: themesHref,
active: currentPath === themesHref,
});
}
return links;
});
// Global spotlight (Cmd+K) — only active when spotlightActions are provided
// svelte-ignore state_referenced_locally
const spotlight = spotlightActions ? createGlobalSpotlightState() : null;
function isActive(path: string) {
return currentPath === path;
}
// User-menu bar — rendered when barMode is active. Short list: settings,
// light/dark/system toggle, theme button.
const userMenuBarItems = $derived.by<PillDropdownItem[]>(() => {
const out: PillDropdownItem[] = [];
if (settingsHref) {
out.push({
id: 'settings',
label: 'Einstellungen',
icon: 'settings',
onClick: () => {
window.location.href = settingsHref;
},
});
}
if (onThemeModeChange) {
out.push(
{
id: 'mode-light',
label: 'Hell',
icon: 'sun',
group: 'theme-mode',
active: themeMode === 'light',
onClick: () => onThemeModeChange('light'),
},
{
id: 'mode-dark',
label: 'Dunkel',
icon: 'moon',
group: 'theme-mode',
active: themeMode === 'dark',
onClick: () => onThemeModeChange('dark'),
},
{
id: 'mode-system',
label: 'System',
icon: 'settings',
group: 'theme-mode',
active: themeMode === 'system',
onClick: () => onThemeModeChange('system'),
}
);
}
if (themesHref) {
out.push({
id: 'themes',
label: 'Theme',
icon: 'palette',
onClick: () => {
window.location.href = themesHref;
},
});
}
if (onLogout && showLogout && userEmail) {
out.push({
id: 'logout',
label: 'Logout',
icon: 'logout',
danger: true,
onClick: () => onLogout(),
});
}
return out;
});
</script>
{#if !(externalCollapsed ?? false)}
<nav
class="pill-nav"
class:pill-nav-static={positioning === 'static'}
style="{primaryColor
? `--pill-primary-color: ${primaryColor};`
: ''}--pill-nav-bottom: {bottomOffset}"
aria-label={ariaLabel}
>
<div class="pill-nav-container">
<!-- App Switcher (optional) -->
{#if showAppSwitcher && appItems.length > 0}
<AppDrawer
apps={appItems}
isOpen={appDrawerOpen}
onToggle={(open) => (appDrawerOpen = open)}
{onOpenInPanel}
{allAppsHref}
{allAppsLabel}
triggerLabel={appName}
/>
{/if}
<!-- Prepended Elements (Tab Groups, Dividers, Nav Items, Tag Selectors) -->
{#each prependElements as element}
{#if isTabGroup(element)}
<PillTabGroup
options={element.options}
value={element.value}
onChange={element.onChange}
sectionLabel={element.sectionLabel}
onContextMenu={element.onContextMenu}
{primaryColor}
/>
{:else if isDivider(element)}
<div class="pill-divider"></div>
{:else if isTagSelector(element)}
<PillTagSelector
tags={element.tags}
selectedIds={element.selectedIds}
onToggle={element.onToggle}
onClear={element.onClear}
onCreate={element.onCreate}
loading={element.loading}
label={element.label}
direction={dropdownDirection}
/>
{:else if isNavItem(element)}
<a href={element.href} class="pill glass-pill" class:active={isActive(element.href)}>
{#if element.icon}
{#if phosphorIcons[element.icon]}
{@const IconComponent = phosphorIcons[element.icon]}
<IconComponent size={18} weight="bold" class="pill-icon" />
{/if}
{/if}
<span class="pill-label">{element.label}</span>
</a>
{/if}
{/each}
<!-- Navigation Items -->
{#each items as item}
{#if item.onClick}
<button
onclick={item.onClick}
oncontextmenu={item.onContextMenu}
class="pill glass-pill"
class:active={item.active}
class:icon-only={item.iconOnly}
aria-label={item.iconOnly ? item.label : undefined}
title={item.iconOnly ? item.label : undefined}
>
{#if item.icon}
{#if item.icon === 'mana'}
<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z"
/>
</svg>
{:else if item.iconSvg}
{@html item.iconSvg}
{:else if phosphorIcons[item.icon]}
{@const IconComponent = phosphorIcons[item.icon]}
<IconComponent size={18} weight="bold" class="pill-icon" />
{/if}
{/if}
{#if !item.iconOnly}
<span class="pill-label">{item.label}</span>
{/if}
</button>
{:else}
<a
href={item.href}
oncontextmenu={item.onContextMenu}
class="pill glass-pill"
class:active={isActive(item.href)}
class:icon-only={item.iconOnly}
aria-label={item.iconOnly ? item.label : undefined}
title={item.iconOnly ? item.label : undefined}
>
{#if item.icon}
{#if item.icon === 'mana'}
<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z"
/>
</svg>
{:else if item.iconSvg}
{@html item.iconSvg}
{:else if phosphorIcons[item.icon]}
{@const IconComponent = phosphorIcons[item.icon]}
<IconComponent size={18} weight="bold" class="pill-icon" />
{/if}
{/if}
{#if !item.iconOnly}
<span class="pill-label">{item.label}</span>
{/if}
</a>
{/if}
{/each}
<!-- Additional Elements (Tab Groups, Dividers, Tag Selectors) -->
{#each elements as element}
{#if isTabGroup(element)}
<PillTabGroup
options={element.options}
value={element.value}
onChange={element.onChange}
sectionLabel={element.sectionLabel}
onContextMenu={element.onContextMenu}
{primaryColor}
/>
{:else if isDivider(element)}
<div class="pill-divider"></div>
{:else if isTagSelector(element)}
<PillTagSelector
tags={element.tags}
selectedIds={element.selectedIds}
onToggle={element.onToggle}
onClear={element.onClear}
onCreate={element.onCreate}
loading={element.loading}
label={element.label}
direction={dropdownDirection}
/>
{:else if isNavItem(element)}
<a href={element.href} class="pill glass-pill" class:active={isActive(element.href)}>
{#if element.icon}
{#if phosphorIcons[element.icon]}
{@const IconComponent = phosphorIcons[element.icon]}
<IconComponent size={18} weight="bold" class="pill-icon" />
{/if}
{/if}
<span class="pill-label">{element.label}</span>
</a>
{/if}
{/each}
<!-- Sync Status -->
{#if showSyncStatus && syncStatusItems.length > 0 && barMode}
{@const syncConfig = {
id: 'sync',
label: currentSyncLabel,
icon: 'cloud',
items: syncStatusItems,
}}
<button
type="button"
onclick={() => toggleBar(syncConfig)}
class="pill glass-pill"
class:active={activeBarId === 'sync'}
title={currentSyncLabel}
>
<Cloud size={18} weight="bold" class="pill-icon" />
<span class="pill-label">{currentSyncLabel}</span>
</button>
{:else if showSyncStatus && syncStatusItems.length > 0}
<PillDropdown
items={syncStatusItems}
direction={dropdownDirection}
label={currentSyncLabel}
icon="cloud"
/>
{/if}
<!-- User Menu -->
{#if (userEmail || loginHref) && barMode}
{@const userLabel = userEmail ? truncateEmail(userEmail) : guestMenuLabel}
{@const userBarConfig = {
id: 'user',
label: userLabel,
icon: 'user',
items: userMenuBarItems,
}}
<button
type="button"
onclick={() => toggleBar(userBarConfig)}
class="pill glass-pill icon-only"
class:active={activeBarId === 'user'}
aria-label={userLabel}
title={userLabel}
data-user-menu-trigger
>
<User size={18} weight="bold" class="pill-icon" />
</button>
{:else if userEmail || loginHref}
{@const userLabel = userEmail ? truncateEmail(userEmail) : guestMenuLabel}
<button
bind:this={userMenuTrigger}
type="button"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="pill glass-pill icon-only"
class:active={userMenuOpen}
aria-label={userLabel}
title={userLabel}
data-user-menu-trigger
>
<User size={18} weight="bold" class="pill-icon" />
</button>
{:else if onLogout && showLogout}
<button onclick={onLogout} class="pill glass-pill logout-pill" title="Logout">
<SignOut size={18} weight="bold" class="pill-icon" />
<span class="pill-label">Logout</span>
</button>
{/if}
</div>
</nav>
{/if}
<!-- User Menu Panel (overlay) -->
{#if userMenuOpen}
<UserMenuPanel
{userEmail}
{loginHref}
{accountLinks}
showLogout={showLogout && !!userEmail}
{onLogout}
showAiTier={showAiTierSelector && aiTierItems.length > 0}
{aiTierItems}
{themeMode}
{onThemeModeChange}
{themeVariantItems}
{showA11yQuickToggles}
{a11yContrast}
{onA11yContrastChange}
{a11yReduceMotion}
{onA11yReduceMotionChange}
showLanguageSwitcher={showLanguageSwitcher && languageItems.length > 0}
{languageItems}
onClose={() => (userMenuOpen = false)}
triggerElement={userMenuTrigger}
/>
{/if}
<!-- Global Spotlight (Cmd+K) -->
{#if spotlight && spotlightActions}
<GlobalSpotlight
open={spotlight.isOpen}
onClose={spotlight.close}
apps={appItems}
quickActions={spotlightActions}
placeholder={spotlightPlaceholder}
{contentSearcher}
/>
{/if}
<style>
.pill-nav {
position: fixed;
bottom: var(--pill-nav-bottom, 0px);
left: 0;
right: 0;
z-index: 1000;
/* Unified bar height (see bottomChromeHeight in (app)/+layout.svelte). */
height: calc(56px + env(safe-area-inset-bottom, 0px));
padding-bottom: env(safe-area-inset-bottom, 0px);
display: flex;
align-items: center;
pointer-events: none;
/* Container query context */
container-type: inline-size;
container-name: pillnav;
}
.pill-nav-static {
position: relative;
bottom: auto;
z-index: auto;
}
.pill-nav-container {
display: flex;
align-items: center;
gap: 1rem;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
pointer-events: auto;
padding: 0.5rem 2rem;
/* Default: left-aligned with fit-content */
width: fit-content;
max-width: 100%;
}
/* Center when container has enough space (> 600px) */
@container pillnav (min-width: 600px) {
.pill-nav-container {
margin-left: auto;
margin-right: auto;
}
}
.pill-nav-container::-webkit-scrollbar {
display: none;
}
/* Mobile: tighter padding, icon-only pills */
@media (max-width: 640px) {
.pill-nav-container {
padding: 0.375rem 0.75rem;
gap: 0.5rem;
}
.pill-label {
display: none;
}
.pill {
padding: 0.625rem;
min-width: 44px;
min-height: 44px;
justify-content: center;
}
.pill-icon {
width: 1.5rem;
height: 1.5rem;
}
}
/* Base pill styles */
.pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0 0.875rem;
height: 36px;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
}
/* Solid theme-tokened pill (formerly the "glass" frosted pill).
The class name is kept for backwards compatibility. */
.glass-pill {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
box-shadow:
0 1px 2px hsl(0 0% 0% / 0.05),
0 2px 6px hsl(0 0% 0% / 0.04);
color: hsl(var(--color-foreground));
}
.glass-pill:hover {
background: hsl(var(--color-surface-hover));
border-color: hsl(var(--color-border-strong, var(--color-border)));
transform: translateY(-2px);
box-shadow:
0 6px 12px hsl(0 0% 0% / 0.08),
0 2px 4px hsl(0 0% 0% / 0.05);
}
/* Active state - uses CSS custom property for theming */
.pill.active {
background: var(--pill-primary-color, var(--color-primary-500, rgba(248, 214, 43, 0.9)));
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #f8d62b)) 20%,
white 80%
);
border-color: var(--pill-primary-color, var(--color-primary-500, rgba(248, 214, 43, 0.5)));
color: #1a1a1a;
}
:global(.dark) .pill.active {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #f8d62b)) 30%,
transparent 70%
);
border-color: var(--pill-primary-color, var(--color-primary-500, rgba(248, 214, 43, 0.4)));
color: var(--pill-primary-color, var(--color-primary-500, #f8d62b));
}
/* Divider */
.pill-divider {
width: 1px;
height: 1.5rem;
background: rgba(0, 0, 0, 0.15);
flex-shrink: 0;
margin: 0 0.25rem;
}
:global(.dark) .pill-divider {
background: rgba(255, 255, 255, 0.2);
}
/* Logout pill */
.logout-pill {
color: #dc2626;
}
:global(.dark) .logout-pill {
color: #ef4444;
}
.logout-pill:hover {
background: rgba(220, 38, 38, 0.15);
border-color: rgba(220, 38, 38, 0.3);
}
.pill-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.pill-label {
display: inline;
}
/* Icon-only pill: wider than tall so it reads as a pill, not a chip. */
.pill.icon-only {
gap: 0;
padding: 0 1.125rem;
}
/* Progress ring on pill (used for download indicator).
Uses a conic-gradient border trick so it follows the pill's
own border-radius regardless of shape. */
/* Transitions */
.pill-nav {
transition: all 0.3s ease;
}
.pill-nav-container {
transition: all 0.3s ease;
}
/* Theme mode selector in dropdown header */
:global(.theme-mode-selector) {
display: flex !important;
align-items: center !important;
gap: 0.25rem !important;
padding: 0.25rem !important;
border-radius: 9999px !important;
background: rgba(245, 245, 245, 0.95) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
color: #374151 !important;
}
:global(.dark .theme-mode-selector) {
background: rgba(40, 40, 40, 0.95) !important;
border: 1px solid rgba(255, 255, 255, 0.15) !important;
color: #f3f4f6 !important;
}
:global(.mode-btn) {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 0.375rem;
border: none;
background: transparent;
border-radius: 9999px;
cursor: pointer;
color: #374151;
transition: all 0.15s;
}
:global(.dark .mode-btn) {
color: #f3f4f6;
}
:global(.mode-btn:hover:not(.active)) {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark .mode-btn:hover:not(.active)) {
background: rgba(255, 255, 255, 0.1);
}
:global(.mode-btn.active) {
background: var(--pill-primary-color, var(--color-primary-500, rgba(248, 214, 43, 0.2)));
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 20%,
white 80%
);
}
:global(.dark .mode-btn.active) {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 30%,
transparent 70%
);
}
:global(.mode-icon) {
width: 1rem;
height: 1rem;
}
/* A11y quick toggles in dropdown footer */
:global(.a11y-quick-toggles) {
display: flex !important;
align-items: center !important;
gap: 0.25rem !important;
padding: 0.25rem !important;
border-radius: 9999px !important;
background: rgba(245, 245, 245, 0.95) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
color: #374151 !important;
}
:global(.dark .a11y-quick-toggles) {
background: rgba(40, 40, 40, 0.95) !important;
border: 1px solid rgba(255, 255, 255, 0.15) !important;
color: #f3f4f6 !important;
}
:global(.a11y-btn) {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
border: none;
background: transparent;
border-radius: 9999px;
cursor: pointer;
color: #6b7280;
transition: all 0.15s;
}
:global(.dark .a11y-btn) {
color: #9ca3af;
}
:global(.a11y-btn:hover:not(.active)) {
background: rgba(0, 0, 0, 0.05);
color: #374151;
}
:global(.dark .a11y-btn:hover:not(.active)) {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
:global(.a11y-btn.active) {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 20%,
white 80%
);
color: var(--pill-primary-color, var(--color-primary-500, #3b82f6));
}
:global(.dark .a11y-btn.active) {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 30%,
transparent 70%
);
}
:global(.a11y-icon) {
width: 1rem;
height: 1rem;
}
</style>