feat(pillnav): compact nav with user menu overlay panel

Replace inline PillDropdownBar for user menu with a centered overlay
panel (UserMenuPanel). Move AI tier, theme, and language selectors
into the panel. Make app switcher and user pill icon-only. AI section
split into "Textgenerierung" and "Spracherkennung" subsections.

- AppDrawer trigger: icon-only (no label/chevron)
- User pill: icon-only, opens overlay panel instead of bar
- Theme + AI pills removed from nav bar (now in user panel)
- UserMenuPanel: centered on desktop, bottom-sheet on mobile
- Login button in footer, structured sections with subsection headers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-12 21:46:39 +02:00
parent f47ea966df
commit 55b7a8a2ef
4 changed files with 887 additions and 535 deletions

View file

@ -99,25 +99,16 @@
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="app-drawer" onkeydown={handleKeydown} role="navigation">
<!-- Trigger Button -->
<button bind:this={triggerButton} onclick={toggle} class="pill glass-pill trigger-button">
<button
bind:this={triggerButton}
onclick={toggle}
class="pill glass-pill trigger-button icon-only"
aria-label={triggerLabel}
title={triggerLabel}
>
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={iconPaths.grid} />
</svg>
<span class="pill-label">{triggerLabel}</span>
<svg
class="chevron-icon"
class:rotated={isOpen}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={iconPaths.chevronDown}
/>
</svg>
</button>
{#if isOpen}
@ -303,19 +294,9 @@
flex-shrink: 0;
}
.pill-label {
display: inline;
}
.chevron-icon {
width: 0.75rem;
height: 0.75rem;
transition: transform 0.2s;
margin-left: 0.25rem;
}
.chevron-icon.rotated {
transform: rotate(180deg);
.trigger-button.icon-only {
gap: 0;
padding: 0.5rem 0.625rem;
}
/* Solid theme-tokened pill (formerly the "glass" frosted pill). */

View file

@ -15,6 +15,7 @@
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)
@ -406,173 +407,6 @@
// Build the flat PillDropdownItem list for each bar, matching what the
// equivalent PillDropdown would render. Mode toggles + variants + a11y
// toggles for theme; tier/sync items pass through; user menu is assembled
// from the same rules as the PillDropdown below.
const themeBarItems = $derived.by<PillDropdownItem[]>(() => {
const out: PillDropdownItem[] = [];
if (onThemeModeChange) {
out.push(
{
id: 'theme-mode-light',
label: 'Light',
icon: 'sun',
group: 'theme-mode',
onClick: () => onThemeModeChange('light'),
active: themeMode === 'light',
},
{
id: 'theme-mode-dark',
label: 'Dark',
icon: 'moon',
group: 'theme-mode',
onClick: () => onThemeModeChange('dark'),
active: themeMode === 'dark',
},
{
id: 'theme-mode-system',
label: 'System',
icon: 'settings',
group: 'theme-mode',
onClick: () => onThemeModeChange('system'),
active: themeMode === 'system',
}
);
}
if (themeVariantItems.length > 0) {
if (out.length > 0) out.push({ id: 'theme-variants-div', label: '', divider: true });
for (const v of themeVariantItems) out.push(v);
}
if (showA11yQuickToggles) {
out.push({ id: 'a11y-div', label: '', divider: true });
if (onA11yContrastChange) {
out.push({
id: 'a11y-contrast',
label: 'Hoher Kontrast',
icon: 'sun',
onClick: () => onA11yContrastChange(a11yContrast === 'high' ? 'normal' : 'high'),
active: a11yContrast === 'high',
});
}
if (onA11yReduceMotionChange) {
out.push({
id: 'a11y-reduce-motion',
label: 'Animationen reduzieren',
icon: 'check',
onClick: () => onA11yReduceMotionChange(!a11yReduceMotion),
active: a11yReduceMotion,
});
}
}
return out;
});
const userBarItems = $derived.by<PillDropdownItem[]>(() => {
const out: PillDropdownItem[] = [];
if (userEmail && profileHref) {
out.push({
id: 'profile',
label: 'Profil',
icon: 'user',
onClick: () => {
window.location.href = profileHref!;
},
active: currentPath === profileHref,
});
}
out.push({
id: 'settings',
label: 'Einstellungen',
icon: 'settings',
onClick: () => {
window.location.href = settingsHref;
},
active: currentPath === settingsHref,
});
if (userEmail && manaHref) {
out.push({
id: 'mana',
label: 'Mana',
icon: 'sparkle',
onClick: () => {
window.location.href = manaHref!;
},
active: currentPath === manaHref,
});
}
if (spiralHref) {
out.push({
id: 'spiral',
label: 'Spiral',
icon: 'spiral',
onClick: () => {
window.location.href = spiralHref!;
},
active: currentPath === spiralHref,
});
}
if (creditsHref) {
out.push({
id: 'credits',
label: 'Credits',
icon: 'creditCard',
onClick: () => {
window.location.href = creditsHref!;
},
active: currentPath === creditsHref,
});
}
if (userEmail && feedbackHref) {
out.push({
id: 'feedback',
label: 'Feedback',
icon: 'chat',
onClick: () => {
window.location.href = feedbackHref!;
},
active: currentPath === feedbackHref,
});
}
if (helpHref) {
out.push({
id: 'help',
label: 'Hilfe',
icon: 'help',
onClick: () => {
window.location.href = helpHref!;
},
active: currentPath === helpHref,
});
}
if (showLanguageSwitcher && languageItems.length > 0) {
out.push({ id: 'language-div', label: '', divider: true });
out.push({
id: 'language',
label: currentLanguageLabel,
submenu: languageItems.map((item) => ({ ...item, id: `lang-${item.id}` })),
});
}
out.push({ id: 'auth-div', label: '', divider: true });
if (userEmail && showLogout && onLogout) {
out.push({
id: 'logout',
label: 'Logout',
icon: 'logout',
onClick: () => onLogout!(),
danger: true,
});
} else if (!userEmail && loginHref) {
out.push({
id: 'login',
label: 'Anmelden',
icon: 'user',
primary: true,
onClick: () => {
window.location.href = loginHref!;
},
});
}
return out;
});
function toggleBar(config: PillBarConfig) {
if (!onOpenBar) return;
@ -616,6 +450,92 @@
// 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;
@ -791,149 +711,6 @@
{/if}
{/each}
<!-- Theme Variant Selector -->
{#if showThemeVariants && themeVariantItems.length > 0 && barMode}
{@const themeConfig = {
id: 'theme',
label: '',
icon: undefined,
items: themeBarItems,
}}
<button
type="button"
onclick={() => toggleBar(themeConfig)}
class="pill glass-pill icon-only"
class:active={activeBarId === 'theme'}
title={currentThemeVariantLabel}
aria-label={currentThemeVariantLabel}
>
<Palette size={18} class="pill-icon" />
</button>
{:else if showThemeVariants && themeVariantItems.length > 0}
<PillDropdown
items={themeVariantItems}
direction={dropdownDirection}
label={currentThemeVariantLabel}
icon="palette"
>
{#snippet header()}
<div class="theme-mode-selector glass-pill">
<button
type="button"
onclick={() => onThemeModeChange?.('light')}
class="mode-btn"
class:active={themeMode === 'light'}
title="Light mode"
>
<Sun size={16} class="mode-icon" />
</button>
<button
type="button"
onclick={() => onThemeModeChange?.('dark')}
class="mode-btn"
class:active={themeMode === 'dark'}
title="Dark mode"
>
<Moon size={16} class="mode-icon" />
</button>
<button
type="button"
onclick={() => onThemeModeChange?.('system')}
class="mode-btn"
class:active={themeMode === 'system'}
title="System mode"
>
<svg class="mode-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="2" y="3" width="20" height="14" rx="2" stroke-width="2" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 21h8M12 17v4"
/>
</svg>
</button>
</div>
{/snippet}
{#snippet footer()}
{#if showA11yQuickToggles}
<div class="a11y-quick-toggles glass-pill">
<!-- Contrast Toggle -->
<button
type="button"
onclick={() =>
onA11yContrastChange?.(a11yContrast === 'high' ? 'normal' : 'high')}
class="a11y-btn"
class:active={a11yContrast === 'high'}
title="Hoher Kontrast"
aria-pressed={a11yContrast === 'high'}
>
<Sun size={20} class="a11y-icon" />
</button>
<!-- Reduce Motion Toggle -->
<button
type="button"
onclick={() => onA11yReduceMotionChange?.(!a11yReduceMotion)}
class="a11y-btn"
class:active={a11yReduceMotion}
title="Animationen reduzieren"
aria-pressed={a11yReduceMotion}
>
<svg
class="a11y-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{#if a11yReduceMotion}
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
{:else}
<polygon points="5 3 19 12 5 21 5 3" />
{/if}
</svg>
</button>
</div>
{/if}
{/snippet}
</PillDropdown>
{/if}
<!-- AI Tier Selector -->
{#if showAiTierSelector && aiTierItems.length > 0 && barMode}
{@const aiProgress = aiTierItems.find((i) => i.progress != null)?.progress}
{@const aiConfig = {
id: 'ai',
label: '',
icon: undefined,
items: aiTierItems,
progress: aiProgress,
}}
{@const AiIcon = phosphorIcons[currentAiTierIcon]}
<button
type="button"
onclick={() => toggleBar(aiConfig)}
class="pill glass-pill icon-only"
class:active={activeBarId === 'ai'}
class:downloading={aiProgress != null}
title={currentAiTierLabel}
aria-label={currentAiTierLabel}
style={aiProgress != null ? `--progress: ${aiProgress}` : ''}
>
{#if AiIcon}
<AiIcon size={18} class="pill-icon" />
{/if}
</button>
{:else if showAiTierSelector && aiTierItems.length > 0}
<PillDropdown
items={aiTierItems}
direction={dropdownDirection}
label={currentAiTierLabel}
icon={currentAiTierIcon}
/>
{/if}
<!-- Sync Status -->
{#if showSyncStatus && syncStatusItems.length > 0 && barMode}
{@const syncConfig = {
@ -961,178 +738,21 @@
/>
{/if}
<!-- Theme Toggle (only show when not using theme variants dropdown) -->
{#if showThemeToggle && onToggleTheme && !showThemeVariants}
<button
onclick={onToggleTheme}
class="pill glass-pill"
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{#if !isDark}
<Moon size={18} class="pill-icon" />
{:else}
<Sun size={18} class="pill-icon" />
{/if}
<span class="pill-label">{isDark ? 'Light' : 'Dark'}</span>
</button>
{/if}
<!-- User Menu Dropdown — rendered for both authenticated users and
guests. Auth-only items (profile/settings/logout) are filtered
out when userEmail is empty; spiral/credits/themes/help stay
available either way so guests can still navigate. -->
{#if (userEmail || loginHref) && barMode}
<!-- User Menu -->
{#if userEmail || loginHref}
{@const userLabel = userEmail ? truncateEmail(userEmail) : guestMenuLabel}
{@const userConfig = {
id: 'user',
label: userLabel,
icon: 'user',
items: userBarItems,
}}
<button
bind:this={userMenuTrigger}
type="button"
onclick={() => toggleBar(userConfig)}
class="pill glass-pill"
class:active={activeBarId === 'user'}
onclick={() => (userMenuOpen = !userMenuOpen)}
class="pill glass-pill icon-only"
class:active={userMenuOpen}
aria-label={userLabel}
title={userLabel}
>
<User size={18} class="pill-icon" />
<span class="pill-label">{userLabel}</span>
</button>
{:else if userEmail || loginHref}
<PillDropdown
items={[
...(userEmail && profileHref
? [
{
id: 'profile',
label: 'Profil',
icon: 'user',
onClick: () => {
window.location.href = profileHref;
},
active: currentPath === profileHref,
},
]
: []),
{
id: 'settings',
label: 'Einstellungen',
icon: 'settings',
onClick: () => {
window.location.href = settingsHref;
},
active: currentPath === settingsHref,
},
...(userEmail && manaHref
? [
{
id: 'mana',
label: 'Mana',
icon: 'sparkle',
onClick: () => {
window.location.href = manaHref;
},
active: currentPath === manaHref,
},
]
: []),
...(spiralHref
? [
{
id: 'spiral',
label: 'Spiral',
icon: 'spiral',
onClick: () => {
window.location.href = spiralHref;
},
active: currentPath === spiralHref,
},
]
: []),
...(creditsHref
? [
{
id: 'credits',
label: 'Credits',
icon: 'creditCard',
onClick: () => {
window.location.href = creditsHref;
},
active: currentPath === creditsHref,
},
]
: []),
...(userEmail && feedbackHref
? [
{
id: 'feedback',
label: 'Feedback',
icon: 'chat',
onClick: () => {
window.location.href = feedbackHref;
},
active: currentPath === feedbackHref,
},
]
: []),
...(helpHref
? [
{
id: 'help',
label: 'Hilfe',
icon: 'help',
onClick: () => {
window.location.href = helpHref;
},
active: currentPath === helpHref,
},
]
: []),
...(showLanguageSwitcher && languageItems.length > 0
? [
{ id: 'language-divider', label: '', divider: true },
{
id: 'language',
label: currentLanguageLabel,
submenu: languageItems.map((item) => ({
...item,
id: `lang-${item.id}`,
})),
},
]
: []),
{ id: 'auth-divider', label: '', divider: true },
...(userEmail && showLogout && onLogout
? [
{
id: 'logout',
label: 'Logout',
icon: 'logout',
onClick: () => onLogout?.(),
danger: true,
},
]
: !userEmail && loginHref
? [
{
id: 'login',
label: 'Anmelden',
icon: 'user',
primary: true,
onClick: () => {
window.location.href = loginHref;
},
},
]
: []),
]}
direction={dropdownDirection}
label={userEmail ? truncateEmail(userEmail) : guestMenuLabel}
icon="user"
/>
{:else if onLogout && showLogout}
<!-- Fallback to standalone logout if no user email and no loginHref -->
<button onclick={onLogout} class="pill glass-pill logout-pill" title="Logout">
<SignOut size={18} class="pill-icon" />
<span class="pill-label">Logout</span>
@ -1142,6 +762,31 @@
</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
@ -1328,38 +973,6 @@
/* 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. */
.pill.downloading {
position: relative;
overflow: visible;
}
.pill.downloading::after {
content: '';
position: absolute;
inset: -3px;
border-radius: inherit;
border: 2.5px solid transparent;
background:
conic-gradient(
from 0deg,
var(--pill-primary-color, var(--color-primary-500, #6366f1))
calc(var(--progress) * 360deg),
transparent calc(var(--progress) * 360deg)
)
border-box,
linear-gradient(hsl(var(--color-card)), hsl(var(--color-card))) padding-box;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
pointer-events: none;
transition: none;
}
/* Transitions */
.pill-nav {
transition: all 0.3s ease;

View file

@ -0,0 +1,757 @@
<script lang="ts">
import type { PillDropdownItem } from './types';
import {
ChatCircle,
Clock,
Cloud,
CreditCard,
Gear,
Globe,
Heart,
Moon,
Palette,
Question,
Robot,
SignOut,
Sparkle,
Spiral,
Sun,
User,
} from '@mana/shared-icons';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const icons: Record<string, any> = {
user: User,
settings: Gear,
sparkle: Sparkle,
spiral: Spiral,
creditCard: CreditCard,
chat: ChatCircle,
help: Question,
heart: Heart,
clock: Clock,
globe: Globe,
cloud: Cloud,
moon: Moon,
sun: Sun,
palette: Palette,
robot: Robot,
logout: SignOut,
};
interface AccountLink {
id: string;
label: string;
icon: string;
href: string;
active?: boolean;
}
interface Props {
// Account
userEmail?: string;
loginHref?: string;
accountLinks?: AccountLink[];
showLogout?: boolean;
onLogout?: () => void;
// AI tier
showAiTier?: boolean;
aiTierItems?: PillDropdownItem[];
// Theme
themeMode?: 'light' | 'dark' | 'system';
onThemeModeChange?: (mode: 'light' | 'dark' | 'system') => void;
themeVariantItems?: PillDropdownItem[];
// A11y
showA11yQuickToggles?: boolean;
a11yContrast?: 'normal' | 'high';
onA11yContrastChange?: (v: 'normal' | 'high') => void;
a11yReduceMotion?: boolean;
onA11yReduceMotionChange?: (v: boolean) => void;
// Language
showLanguageSwitcher?: boolean;
languageItems?: PillDropdownItem[];
// Panel
onClose: () => void;
triggerElement?: HTMLElement;
}
let {
userEmail,
loginHref,
accountLinks = [],
showLogout = false,
onLogout,
showAiTier = false,
aiTierItems = [],
themeMode = 'system',
onThemeModeChange,
themeVariantItems = [],
showA11yQuickToggles = false,
a11yContrast = 'normal',
onA11yContrastChange,
a11yReduceMotion = false,
onA11yReduceMotionChange,
showLanguageSwitcher = false,
languageItems = [],
onClose,
triggerElement,
}: Props = $props();
let panelBottom = $state(0);
$effect(() => {
if (triggerElement) {
const rect = triggerElement.getBoundingClientRect();
panelBottom = window.innerHeight - rect.top + 8;
}
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
function handleItemClick(item: PillDropdownItem, event: MouseEvent) {
if (item.disabled) return;
item.onClick?.(event);
}
function navigateTo(href: string) {
window.location.href = href;
onClose();
}
// Split AI items into LLM, STT, and extra sections by dividers
const aiSections = $derived.by(() => {
const llm: PillDropdownItem[] = [];
const stt: PillDropdownItem[] = [];
const extra: PillDropdownItem[] = [];
let section: 'llm' | 'stt' | 'extra' = 'llm';
for (const item of aiTierItems) {
if (item.divider && item.id === 'stt-divider') {
section = 'stt';
continue;
}
if (item.divider && item.id === 'ai-divider') {
section = 'extra';
continue;
}
if (item.divider) continue;
if (section === 'llm') llm.push(item);
else if (section === 'stt') stt.push(item);
else extra.push(item);
}
return { llm, stt, extra };
});
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_interactive_supports_focus -->
<div
class="user-menu-panel-root"
onkeydown={handleKeydown}
role="dialog"
aria-label="Menu"
tabindex="-1"
>
<!-- Backdrop -->
<button class="panel-backdrop" onclick={onClose} aria-label="Menü schließen"></button>
<!-- Panel -->
<div class="panel" style="bottom: {panelBottom}px;">
<!-- Header -->
{#if userEmail}
<div class="panel-header">
<User size={18} />
<span class="header-email">{userEmail}</span>
</div>
{/if}
<div class="panel-content">
<!-- Account Links -->
{#if accountLinks.length > 0}
<div class="panel-section">
<div class="section-header">Account</div>
<div class="chip-grid">
{#each accountLinks as link (link.id)}
<button
class="chip"
class:active={link.active}
onclick={() => navigateTo(link.href)}
title={link.label}
>
{#if icons[link.icon]}
{@const Icon = icons[link.icon]}
<Icon size={16} />
{/if}
<span>{link.label}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- AI Tier -->
{#if showAiTier && aiTierItems.length > 0}
<div class="panel-section">
<div class="section-header">Künstliche Intelligenz</div>
<!-- LLM subsection -->
{#if aiSections.llm.length > 0}
<div class="subsection">
<div class="subsection-header">Textgenerierung</div>
<div class="chip-grid">
{#each aiSections.llm as item (item.id)}
<button
class="chip"
class:active={item.active}
disabled={item.disabled}
onclick={(e) => handleItemClick(item, e)}
title={item.label}
>
{#if item.progress != null}
<svg class="progress-ring" viewBox="0 0 20 20">
<circle class="progress-bg" cx="10" cy="10" r="8" />
<circle
class="progress-fill"
cx="10"
cy="10"
r="8"
stroke-dasharray={8 * 2 * Math.PI}
stroke-dashoffset={8 * 2 * Math.PI * (1 - item.progress)}
/>
</svg>
{:else if item.icon && icons[item.icon]}
{@const Icon = icons[item.icon]}
<Icon size={16} />
{/if}
<span>{item.label}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- Whisper subsection -->
{#if aiSections.stt.length > 0}
<div class="subsection">
<div class="subsection-header">Spracherkennung</div>
<div class="chip-grid">
{#each aiSections.stt as item (item.id)}
<button
class="chip"
class:active={item.active}
disabled={item.disabled}
onclick={(e) => handleItemClick(item, e)}
title={item.label}
>
{#if item.progress != null}
<svg class="progress-ring" viewBox="0 0 20 20">
<circle class="progress-bg" cx="10" cy="10" r="8" />
<circle
class="progress-fill"
cx="10"
cy="10"
r="8"
stroke-dasharray={8 * 2 * Math.PI}
stroke-dashoffset={8 * 2 * Math.PI * (1 - item.progress)}
/>
</svg>
{:else if item.icon && icons[item.icon]}
{@const Icon = icons[item.icon]}
<Icon size={16} />
{/if}
<span>{item.label}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- Extra (e.g. KI-Einstellungen link) -->
{#if aiSections.extra.length > 0}
<div class="chip-grid" style="margin-top: 0.5rem;">
{#each aiSections.extra as item (item.id)}
<button
class="chip"
class:active={item.active}
disabled={item.disabled}
onclick={(e) => handleItemClick(item, e)}
title={item.label}
>
{#if item.icon && icons[item.icon]}
{@const Icon = icons[item.icon]}
<Icon size={16} />
{/if}
<span>{item.label}</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Theme -->
{#if onThemeModeChange || themeVariantItems.length > 0}
<div class="panel-section">
<div class="section-header">Theme</div>
{#if onThemeModeChange}
<div class="segmented-toggle">
<button
class="segmented-btn"
class:active={themeMode === 'light'}
onclick={() => onThemeModeChange('light')}
title="Light"
>
<Sun size={16} />
<span>Light</span>
</button>
<button
class="segmented-btn"
class:active={themeMode === 'dark'}
onclick={() => onThemeModeChange('dark')}
title="Dark"
>
<Moon size={16} />
<span>Dark</span>
</button>
<button
class="segmented-btn"
class:active={themeMode === 'system'}
onclick={() => onThemeModeChange('system')}
title="System"
>
<Gear size={16} />
<span>Auto</span>
</button>
</div>
{/if}
{#if themeVariantItems.length > 0}
<div class="chip-grid" style="margin-top: 0.5rem;">
{#each themeVariantItems as item (item.id)}
<button
class="chip"
class:active={item.active}
disabled={item.disabled}
onclick={(e) => handleItemClick(item, e)}
title={item.label}
>
{#if item.imageUrl}
<img src={item.imageUrl} alt="" class="chip-img" />
{:else if item.icon && icons[item.icon]}
{@const Icon = icons[item.icon]}
<Icon size={16} />
{/if}
<span>{item.label}</span>
</button>
{/each}
</div>
{/if}
{#if showA11yQuickToggles}
<div class="a11y-row" style="margin-top: 0.5rem;">
{#if onA11yContrastChange}
<button
class="chip"
class:active={a11yContrast === 'high'}
onclick={() => onA11yContrastChange(a11yContrast === 'high' ? 'normal' : 'high')}
>
<Sun size={16} />
<span>Kontrast</span>
</button>
{/if}
{#if onA11yReduceMotionChange}
<button
class="chip"
class:active={a11yReduceMotion}
onclick={() => onA11yReduceMotionChange(!a11yReduceMotion)}
>
<Gear size={16} />
<span>Weniger Animationen</span>
</button>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Language -->
{#if showLanguageSwitcher && languageItems.length > 0}
<div class="panel-section">
<div class="section-header">Sprache</div>
<div class="segmented-toggle">
{#each languageItems as item (item.id)}
<button
class="segmented-btn"
class:active={item.active}
onclick={(e) => handleItemClick(item, e)}
title={item.label}
>
<span>{item.label}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Footer: Login / Logout -->
{#if !userEmail && loginHref}
<div class="panel-footer">
<button class="login-btn" onclick={() => navigateTo(loginHref)}>
<User size={16} />
<span>Anmelden</span>
</button>
</div>
{:else if userEmail && showLogout && onLogout}
<div class="panel-footer">
<button
class="logout-btn"
onclick={() => {
onLogout();
onClose();
}}
>
<SignOut size={16} />
<span>Logout</span>
</button>
</div>
{/if}
</div>
</div>
<style>
.user-menu-panel-root {
position: relative;
z-index: 10000;
}
/* Backdrop */
.panel-backdrop {
position: fixed;
inset: 0;
z-index: 9998;
background: rgba(0, 0, 0, 0.1);
border: none;
cursor: default;
}
:global(.dark) .panel-backdrop {
background: rgba(0, 0, 0, 0.3);
}
/* Panel */
.panel {
position: fixed;
z-index: 9999;
width: 520px;
max-height: 85vh;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 1rem;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 8px 10px -6px rgba(0, 0, 0, 0.1);
animation: panelIn 0.15s ease-out;
overflow: hidden;
}
@keyframes panelIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* Header */
.panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(var(--color-border));
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.header-email {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Content */
.panel-content {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
/* Sections */
.panel-section {
padding: 0.375rem 0.5rem;
}
.panel-section + .panel-section {
border-top: 1px solid hsl(var(--color-border));
margin-top: 0.25rem;
padding-top: 0.625rem;
}
.section-header {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
padding-bottom: 0.375rem;
}
.subsection {
margin-top: 0.375rem;
}
.subsection:first-child {
margin-top: 0;
}
.subsection-header {
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.03em;
color: hsl(var(--color-muted-foreground));
opacity: 0.7;
padding-bottom: 0.25rem;
}
/* Chip grid */
.chip-grid,
.a11y-row {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: all 0.15s;
color: hsl(var(--color-foreground));
}
.chip:hover:not(:disabled) {
background: hsl(var(--color-surface-hover, var(--color-muted)));
transform: translateY(-1px);
}
.chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chip.active {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #6366f1)) 20%,
white 80%
);
border-color: var(--pill-primary-color, var(--color-primary-500, rgba(99, 102, 241, 0.4)));
color: #1a1a1a;
}
:global(.dark) .chip.active {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #6366f1)) 30%,
transparent 70%
);
color: var(--pill-primary-color, var(--color-primary-500, #6366f1));
}
.chip-img {
width: 16px;
height: 16px;
border-radius: 4px;
object-fit: cover;
}
/* Segmented toggle */
.segmented-toggle {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
box-shadow:
0 1px 2px hsl(0 0% 0% / 0.05),
0 2px 6px hsl(0 0% 0% / 0.04);
}
.segmented-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border: none;
background: transparent;
border-radius: 9999px;
cursor: pointer;
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
transition: all 0.15s;
white-space: nowrap;
}
.segmented-btn:hover:not(.active):not(:disabled) {
background: hsl(var(--color-surface-hover, var(--color-muted)));
}
.segmented-btn.active {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #6366f1)) 20%,
white 80%
);
}
:global(.dark) .segmented-btn.active {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #6366f1)) 30%,
transparent 70%
);
}
.segmented-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Progress ring */
.progress-ring {
width: 20px;
height: 20px;
transform: rotate(-90deg);
flex-shrink: 0;
}
.progress-bg {
fill: none;
stroke: hsl(var(--color-border));
stroke-width: 2;
}
.progress-fill {
fill: none;
stroke: var(--pill-primary-color, var(--color-primary-500, #6366f1));
stroke-width: 2.5;
stroke-linecap: round;
transition: stroke-dashoffset 0.3s ease;
}
/* Login button */
.login-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.875rem;
border-radius: 9999px;
border: none;
background: var(--pill-primary-color, var(--color-primary-500, #6366f1));
color: white;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
justify-content: center;
}
.login-btn:hover {
opacity: 0.9;
}
/* Footer */
.panel-footer {
border-top: 1px solid hsl(var(--color-border));
padding: 0.5rem;
}
.logout-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.875rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
color: #dc2626;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
justify-content: center;
}
:global(.dark) .logout-btn {
color: #ef4444;
}
.logout-btn:hover {
background: hsl(var(--color-surface-hover, var(--color-muted)));
}
/* Mobile: bottom sheet */
@media (max-width: 640px) {
.panel {
position: fixed;
top: auto !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100%;
max-height: 80vh;
border-radius: 1rem 1rem 0 0;
animation: slideUp 0.2s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.panel-backdrop {
background: rgba(0, 0, 0, 0.3);
}
}
</style>

View file

@ -6,6 +6,7 @@ export { default as PillNavigation } from './PillNavigation.svelte';
export { default as PillDropdown } from './PillDropdown.svelte';
export { default as PillDropdownBar } from './PillDropdownBar.svelte';
export { default as AppDrawer } from './AppDrawer.svelte';
export { default as UserMenuPanel } from './UserMenuPanel.svelte';
export { default as GlobalSpotlight } from './GlobalSpotlight.svelte';
export { createGlobalSpotlightState } from './useGlobalSpotlight.svelte';
export {