feat(web): PillNav bar mode, fullscreen, local STT + mic button

PillNav overhaul:
- Dropdown-as-bar: theme/AI/sync/user menus render as horizontal
  bars in the bottom stack (PillDropdownBar) instead of floating
  popovers. New onOpenBar/activeBarId props on PillNavigation.
- iconOnly pills: tags/search/workbench-tabs pills show only icons.
  Home pill removed. New iconOnly flag on PillNavItem.
- Segmented toggle groups: items sharing a `group` id render as a
  single segmented pill (e.g. Light/Dark/System triple).
- Fullscreen mode: press "f" to hide all bottom chrome, Esc to exit.
- QuickInputBar + bottom bar visibility toggles via new pills.
- Progress ring on AI trigger pill during model download
  (conic-gradient ::after, follows pill border-radius).

@mana/local-stt — new package for browser-local speech-to-text:
- Whisper models via transformers.js v4 (WebGPU + WASM fallback)
- Same Web Worker architecture as @mana/local-llm
- Two models: Whisper Tiny (150 MB) and Whisper Small (950 MB)
- Reactive Svelte 5 bindings (getLocalSttStatus, loadLocalStt, transcribe)

Voice-to-text integration:
- useLocalStt() composable: mic capture via AudioContext +
  ScriptProcessor, resample to 16kHz mono, feed into Whisper worker
- Mic button in QuickInputBar (leftAction slot) with
  recording/loading/transcribing states + pulse animation
- Transcribed text injected into InputBar via new injectedText prop
- STT model selector in AI bar alongside LLM tier controls

Also: vite.config.ts server.fs.allow expanded to monorepo root
so workspace package workers resolve in dev.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-12 16:05:43 +02:00
parent 8c2f9306e9
commit 3deee755b3
24 changed files with 2145 additions and 28 deletions

View file

@ -100,6 +100,7 @@ export {
SidebarSection,
PillNavigation,
PillDropdown,
PillDropdownBar,
AppDrawer,
GlobalSpotlight,
createGlobalSpotlightState,
@ -129,6 +130,7 @@ export type {
PillNavItem,
PillDropdownItem,
PillNavElement,
PillBarConfig,
PillNavigationProps,
PillTabOption,
PillTabGroupConfig,

View file

@ -0,0 +1,510 @@
<script lang="ts">
import type { PillDropdownItem } from './types';
import {
Archive,
Bell,
Buildings,
CalendarBlank,
CaretDown,
ChartBar,
ChatCircle,
Check,
CheckCircle,
CheckSquare,
Clock,
Cloud,
Columns,
Compass,
CreditCard,
File,
FileText,
Fire,
Folder,
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,
Sun,
Tag,
Target,
Timer,
Trash,
Tray,
Upload,
User,
Users,
Waveform,
} from '@mana/shared-icons';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const phosphorIcons: Record<string, any> = {
home: House,
users: Users,
user: User,
tag: Tag,
heart: Heart,
settings: Gear,
chat: ChatCircle,
'help-circle': Question,
help: 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,
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,
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,
cloud: Cloud,
};
interface Props {
/** Items to render as pills in the bar */
items: PillDropdownItem[];
/** Label shown at the start of the bar (title of the opened dropdown) */
label?: string;
/** Icon rendered next to the label */
icon?: string;
/** Use 'static' when inside a flex container (bottom-stack pattern). */
positioning?: 'fixed' | 'static';
}
let { items, label, icon, positioning = 'static' }: Props = $props();
// A render element is either a single item, a divider/section-label, or a
// group of items that share the same `group` id (rendered as a segmented
// toggle pill).
type RenderElement =
| { kind: 'item'; id: string; item: PillDropdownItem }
| { kind: 'divider'; id: string }
| { kind: 'section-label'; id: string; label: string }
| { kind: 'group'; id: string; items: PillDropdownItem[] };
const renderElements = $derived.by<RenderElement[]>(() => {
const out: RenderElement[] = [];
// Track groups already emitted so we only render each once.
const emittedGroups = new Set<string>();
// First flatten submenus, then collect groups.
const flat: PillDropdownItem[] = [];
for (const item of items) {
if (item.submenu && item.submenu.length > 0) {
flat.push({ id: `${item.id}-section`, label: item.label, divider: true });
for (const child of item.submenu) flat.push(child);
} else {
flat.push(item);
}
}
for (const item of flat) {
if (item.divider) {
const hasLabel = !!item.label;
out.push(
hasLabel
? { kind: 'section-label', id: item.id, label: item.label }
: { kind: 'divider', id: item.id }
);
} else if (item.group) {
if (!emittedGroups.has(item.group)) {
emittedGroups.add(item.group);
const grouped = flat.filter((i) => i.group === item.group);
out.push({ kind: 'group', id: `group-${item.group}`, items: grouped });
}
} else {
out.push({ kind: 'item', id: item.id, item });
}
}
return out;
});
function handleClick(item: PillDropdownItem, event: MouseEvent) {
if (item.disabled || item.divider) return;
item.onClick?.(event);
}
</script>
<div class="dropdown-bar-wrapper" class:static={positioning === 'static'}>
<div class="dropdown-bar-container">
{#if label}
<div class="bar-label glass-pill">
{#if icon && phosphorIcons[icon]}
{@const IconComponent = phosphorIcons[icon]}
<IconComponent size={16} />
{/if}
<span>{label}</span>
</div>
{/if}
{#each renderElements as el (el.id)}
{#if el.kind === 'divider'}
<div class="bar-divider"></div>
{:else if el.kind === 'section-label'}
<div class="bar-section-label">{el.label}</div>
{:else if el.kind === 'group'}
<!-- Segmented toggle pill. If any label in the group is longer
than 10 chars the group shows icon+label; otherwise icon-only
(e.g. the Light/Dark/System triple). -->
{@const showLabels = el.items.some((i) => (i.label?.length ?? 0) > 10)}
<div class="segmented-toggle glass-pill" class:with-labels={showLabels}>
{#each el.items as gi (gi.id)}
<button
type="button"
class="segmented-btn"
class:active={gi.active}
class:has-progress={gi.progress != null}
disabled={gi.disabled}
onclick={(e) => handleClick(gi, e)}
title={gi.label}
>
{#if gi.progress != null}
<svg class="progress-ring-inline" 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 - gi.progress)}
/>
</svg>
{:else if gi.icon && phosphorIcons[gi.icon]}
{@const GIcon = phosphorIcons[gi.icon]}
<GIcon size={16} class="segmented-icon" />
{/if}
{#if showLabels}
<span class="segmented-label">{gi.label}</span>
{/if}
</button>
{/each}
</div>
{:else}
{@const item = el.item}
<button
type="button"
class="bar-pill glass-pill"
class:active={item.active}
class:primary={item.primary}
class:danger={item.danger}
disabled={item.disabled}
onclick={(e) => handleClick(item, e)}
title={item.label}
>
{#if item.imageUrl}
<img src={item.imageUrl} alt="" class="bar-img" />
{:else if item.icon && phosphorIcons[item.icon]}
{@const IconComponent = phosphorIcons[item.icon]}
<IconComponent size={16} />
{/if}
<span>{item.label}</span>
</button>
{/if}
{/each}
</div>
</div>
<style>
.dropdown-bar-wrapper {
display: flex;
flex-direction: column;
align-items: stretch;
pointer-events: none;
}
.dropdown-bar-wrapper.static {
position: relative;
}
.dropdown-bar-container {
display: flex;
align-items: center;
gap: 0.5rem;
pointer-events: auto;
width: fit-content;
max-width: 100%;
margin-left: auto;
margin-right: auto;
padding: 0.5rem 2rem;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
mask-image: linear-gradient(
to right,
transparent 0%,
black 2rem,
black calc(100% - 2rem),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to right,
transparent 0%,
black 2rem,
black calc(100% - 2rem),
transparent 100%
);
}
.dropdown-bar-container::-webkit-scrollbar {
display: none;
}
.bar-pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
box-shadow:
0 1px 2px hsl(0 0% 0% / 0.05),
0 2px 6px hsl(0 0% 0% / 0.04);
}
.bar-pill:hover:not(:disabled) {
background: hsl(var(--color-surface-hover, var(--color-card)));
transform: translateY(-1px);
}
.bar-pill:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bar-pill.active {
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) .bar-pill.active {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #f8d62b)) 30%,
transparent 70%
);
color: var(--pill-primary-color, var(--color-primary-500, #f8d62b));
}
.bar-pill.primary {
background: var(--pill-primary-color, var(--color-primary-500, #6366f1));
border-color: transparent;
color: white;
}
.bar-pill.danger {
color: #dc2626;
}
:global(.dark) .bar-pill.danger {
color: #ef4444;
}
.bar-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-muted, var(--color-card)));
color: hsl(var(--color-foreground));
flex-shrink: 0;
}
.bar-divider {
width: 1px;
height: 1.5rem;
background: hsl(var(--color-border));
flex-shrink: 0;
margin: 0 0.125rem;
}
.bar-section-label {
display: inline-flex;
align-items: center;
padding: 0 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
white-space: nowrap;
}
.bar-img {
width: 16px;
height: 16px;
border-radius: 4px;
object-fit: cover;
}
/* Segmented toggle pill (e.g. Light / Dark / System three-way) */
.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);
flex-shrink: 0;
}
.segmented-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
border: none;
background: transparent;
border-radius: 9999px;
cursor: pointer;
color: hsl(var(--color-foreground));
transition: all 0.15s;
}
.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 :global(.segmented-icon) {
width: 1rem;
height: 1rem;
}
/* When the group shows labels, give the buttons more padding */
.segmented-toggle.with-labels .segmented-btn {
gap: 0.375rem;
padding: 0.375rem 0.625rem;
}
.segmented-label {
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
}
/* Inline progress ring (replaces icon when downloading) */
.progress-ring-inline {
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;
}
.segmented-btn.has-progress {
position: relative;
}
</style>

View file

@ -7,6 +7,7 @@
PillTabGroupConfig,
PillTagSelectorConfig,
PillAppItem,
PillBarConfig,
SpotlightAction,
ContentSearcher,
} from './types';
@ -32,6 +33,7 @@
CheckCircle,
CheckSquare,
Clock,
Cloud,
Columns,
Compass,
CreditCard,
@ -140,6 +142,7 @@
share: ShareFat,
trash: Trash,
filter: Funnel,
cloud: Cloud,
};
// Convert app items to dropdown items (will be computed as derived)
@ -326,6 +329,14 @@
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 {
@ -386,8 +397,192 @@
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
// 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;
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';
@ -506,6 +701,9 @@
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'}
@ -521,7 +719,9 @@
<IconComponent size={18} class="pill-icon" />
{/if}
{/if}
<span class="pill-label">{item.label}</span>
{#if !item.iconOnly}
<span class="pill-label">{item.label}</span>
{/if}
</button>
{:else}
<a
@ -529,6 +729,9 @@
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'}
@ -544,7 +747,9 @@
<IconComponent size={18} class="pill-icon" />
{/if}
{/if}
<span class="pill-label">{item.label}</span>
{#if !item.iconOnly}
<span class="pill-label">{item.label}</span>
{/if}
</a>
{/if}
{/each}
@ -587,7 +792,24 @@
{/each}
<!-- Theme Variant Selector -->
{#if showThemeVariants && themeVariantItems.length > 0}
{#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}
@ -679,7 +901,31 @@
{/if}
<!-- AI Tier Selector -->
{#if showAiTierSelector && aiTierItems.length > 0}
{#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}
@ -689,7 +935,24 @@
{/if}
<!-- Sync Status -->
{#if showSyncStatus && syncStatusItems.length > 0}
{#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} class="pill-icon" />
<span class="pill-label">{currentSyncLabel}</span>
</button>
{:else if showSyncStatus && syncStatusItems.length > 0}
<PillDropdown
items={syncStatusItems}
direction={dropdownDirection}
@ -718,7 +981,25 @@
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}
{#if (userEmail || loginHref) && barMode}
{@const userLabel = userEmail ? truncateEmail(userEmail) : guestMenuLabel}
{@const userConfig = {
id: 'user',
label: userLabel,
icon: 'user',
items: userBarItems,
}}
<button
type="button"
onclick={() => toggleBar(userConfig)}
class="pill glass-pill"
class:active={activeBarId === 'user'}
title={userLabel}
>
<User size={18} class="pill-icon" />
<span class="pill-label">{userLabel}</span>
</button>
{:else if userEmail || loginHref}
<PillDropdown
items={[
...(userEmail && profileHref
@ -1038,6 +1319,47 @@
display: inline;
}
/* Icon-only pill: square-ish shape, no label gap */
.pill.icon-only {
gap: 0;
padding: 0.5rem 0.625rem;
}
/* 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

@ -4,6 +4,7 @@ export { default as Sidebar } from './Sidebar.svelte';
export { default as SidebarSection } from './SidebarSection.svelte';
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 GlobalSpotlight } from './GlobalSpotlight.svelte';
export { createGlobalSpotlightState } from './useGlobalSpotlight.svelte';
@ -43,6 +44,7 @@ export type {
PillTagSelectorConfig,
PillDivider,
PillNavElement,
PillBarConfig,
SpotlightAction,
ContentSearchResult,
ContentSearchGroup,

View file

@ -26,6 +26,23 @@ export interface PillNavItem {
active?: boolean;
/** Right-click handler for context menu */
onContextMenu?: (e: MouseEvent) => void;
/** Show only the icon (hide the label). Label is still used for aria-label/title. */
iconOnly?: boolean;
}
/** Config passed when a PillNavigation dropdown should surface as a bar
* in the host's bottom stack instead of an in-place popover. */
export interface PillBarConfig {
/** Stable id (e.g. 'theme', 'ai', 'sync', 'user') */
id: string;
/** Title shown at the start of the bar */
label: string;
/** Icon name shown next to the title */
icon?: string;
/** Items to render as pills */
items: PillDropdownItem[];
/** Progress value 01. When set, a progress ring is shown on the trigger pill. */
progress?: number;
}
export interface PillDropdownItem {
@ -51,6 +68,10 @@ export interface PillDropdownItem {
divider?: boolean;
/** Nested submenu items */
submenu?: PillDropdownItem[];
/** Group id — items sharing the same group are rendered as a segmented toggle pill */
group?: string;
/** Progress value 01. When set, a circular progress ring is rendered around the icon. */
progress?: number;
/** Whether to show a split button for opening in panel */
showSplitButton?: boolean;
/** Click handler for split button */

View file

@ -76,6 +76,9 @@
locale?: string;
/** Use 'static' when inside a flex container (bottom-stack pattern). Default: 'fixed'. */
positioning?: 'fixed' | 'static';
/** Externally injected text (e.g. from voice input). When this changes
* to a non-empty string, the input bar's query is set and focused. */
injectedText?: string;
}
let {
@ -106,6 +109,7 @@
highlightPatterns,
locale = 'de',
positioning = 'fixed',
injectedText,
}: Props = $props();
// Use settings for autoFocus
@ -125,6 +129,18 @@
// Whether search has been explicitly triggered in deferred mode
let searchTriggered = $state(false);
// External text injection (e.g. from voice-to-text). When the prop
// changes to a new non-empty value, set the search query and focus.
let lastInjected = '';
$effect(() => {
if (injectedText && injectedText !== lastInjected) {
lastInjected = injectedText;
searchQuery = injectedText;
// Focus the input so the user sees and can edit the text
requestAnimationFrame(() => inputElement?.focus());
}
});
// Context menu state
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);