fix(spaces): Space-Switcher visible + styled like native PillNav pills

Two problems made the switcher unusable inside the PillNav:

1. Menu was getting clipped — .pill-nav-container has overflow-x: auto,
   which hides any position: absolute child that extends past the bar.
   Switched to position: fixed with getBoundingClientRect coordinates
   (same pattern @mana/shared-ui PillDropdown uses). Menu now escapes
   the bar container cleanly and opens upward on the viewport.
2. Trigger and menu didn't match Pill design tokens. Rewrote the
   styles to mirror Pill.svelte: pill-shaped 36px height, box-shadow,
   hsl(var(--color-card)) background, hsl(var(--color-border)) border,
   active-state color-mix with --pill-primary-color, dark-mode variant.

Other polish:
- Replaced per-type colored backgrounds with a small type-dot + a
  proper type-label chip inside each menu row. Matches the tone of the
  type chips used elsewhere, and the chip adapts to dark mode.
- Full-viewport backdrop button captures click-outside at z=1500.
- Menu z=1501, create dialog z=1601 so the stack is well-ordered
  (PillNav=1000, menu=1501, dialog=1601).
- Chevron rotates on open (matches other PillDropdown affordances).
- Resize/scroll listeners reposition the menu while it's open so the
  anchoring survives layout changes.
- SpaceCreateDialog's backdrop + dialog z-index bumped from 200/201
  to 1600/1601 so it sits above the menu that spawned it.

0 errors across 7201 files.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 21:21:16 +02:00
parent ea673a22c2
commit ea1c9c1364
2 changed files with 374 additions and 157 deletions

View file

@ -223,7 +223,9 @@
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
/* Above the PillNav (z=1000) and the SpaceSwitcher menu (z=1501)
so opening the dialog cleanly covers the nav chrome. */
z-index: 1600;
border: 0;
}
@ -235,11 +237,12 @@
width: min(540px, 92vw);
max-height: 86vh;
overflow-y: auto;
background: var(--color-surface-1, white);
color: var(--color-text, inherit);
border-radius: var(--radius-lg, 10px);
background: hsl(var(--color-card, 0 0% 100%));
color: hsl(var(--color-foreground, 0 0% 10%));
border: 1px solid hsl(var(--color-border, 0 0% 88%));
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
z-index: 201;
z-index: 1601;
}
form {

View file

@ -3,13 +3,14 @@
* SpaceSwitcher — dropdown that shows the active Space and lets the
* user switch to another one or create a new Space.
*
* Minimal and self-contained so it can be dropped anywhere in the
* header without touching the PillNavigation's own config surface.
* Uses plain <button> + absolute-positioned <div> for the dropdown
* — sufficient for Phase 1; can migrate to a shared-ui Popover
* primitive later if the pattern repeats.
* Visual match to @mana/shared-ui Pill / PillDropdown so the trigger
* reads as a native member of the PillNav row. The menu uses fixed
* positioning with getBoundingClientRect coordinates so it escapes
* the PillNav's overflow-x container (which would otherwise clip it).
* A full-viewport backdrop handles click-outside.
*/
import { onDestroy } from 'svelte';
import { getActiveSpace, loadActiveSpace, type ActiveSpace } from '$lib/data/scope';
import { SPACE_TYPE_LABELS } from '@mana/shared-branding';
import { isSpaceType, isSpaceTier } from '@mana/shared-types';
@ -29,13 +30,29 @@
let switching = $state(false);
let loadError = $state<string | null>(null);
let triggerEl: HTMLButtonElement | null = $state(null);
let menuPos = $state({ top: 0, left: 0, minWidth: 240 });
const active = $derived(getActiveSpace());
function typeLabel(type: ActiveSpace['type']): string {
return SPACE_TYPE_LABELS[locale][type];
}
function positionMenu() {
if (!triggerEl) return;
const rect = triggerEl.getBoundingClientRect();
// Open upward: the PillNav sits on viewport bottom; align menu's
// bottom edge to 8px above the trigger top.
menuPos = {
top: rect.top - 8,
left: rect.left,
minWidth: Math.max(240, rect.width),
};
}
async function openDropdown() {
positionMenu();
open = true;
loadingList = true;
loadError = null;
@ -58,7 +75,7 @@
name: o.name,
tier,
type,
role: 'member', // real role comes via /get-active-member; not needed for display
role: 'member',
};
});
} catch (err) {
@ -68,10 +85,14 @@
}
}
function closeDropdown() {
open = false;
}
async function switchTo(id: string) {
if (switching) return;
if (id === active?.id) {
open = false;
closeDropdown();
return;
}
switching = true;
@ -84,10 +105,6 @@
});
if (!res.ok) throw new Error(`set-active failed: ${res.status}`);
await loadActiveSpace({ force: true });
// Full reload so every liveQuery re-evaluates against the new active
// space. A reactive-invalidation path would require each liveQuery
// to re-subscribe on spaceId change; revisit once the scope wrapper
// is used widely enough for that to matter.
if (typeof window !== 'undefined') window.location.reload();
} catch (err) {
loadError = err instanceof Error ? err.message : String(err);
@ -97,219 +114,416 @@
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') open = false;
if (e.key === 'Escape') closeDropdown();
}
function handleResize() {
if (open) positionMenu();
}
if (typeof window !== 'undefined') {
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleResize, true);
}
onDestroy(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleResize, true);
}
});
</script>
<svelte:window onkeydown={handleKey} />
<div class="space-switcher" class:open>
<button
bind:this={triggerEl}
type="button"
class="pill pill-sm trigger"
class:active={open}
aria-haspopup="menu"
aria-expanded={open}
onclick={() => (open ? closeDropdown() : openDropdown())}
>
<span class="name">{active?.name ?? '…'}</span>
{#if active}
<span class="type-dot" data-type={active.type} aria-hidden="true"></span>
{/if}
<svg class="chev" class:rotated={open} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if open}
<button
type="button"
class="trigger"
aria-haspopup="menu"
aria-expanded={open}
onclick={() => (open ? (open = false) : openDropdown())}
>
<span class="name">{active?.name ?? '…'}</span>
{#if active}
<span class="type-badge" data-type={active.type}>{typeLabel(active.type)}</span>
{/if}
<span class="chev" aria-hidden="true"></span>
</button>
class="backdrop"
aria-label="Menü schließen"
onclick={closeDropdown}
tabindex="-1"
></button>
{#if open}
<div class="dropdown" role="menu" aria-label={locale === 'de' ? 'Spaces' : 'Spaces'}>
{#if loadingList}
<div class="empty">{locale === 'de' ? 'Lädt …' : 'Loading …'}</div>
{:else if loadError}
<div class="error">{loadError}</div>
{:else if spaces.length === 0}
<div class="empty">
{locale === 'de' ? 'Keine Spaces' : 'No spaces'}
</div>
{:else}
{#each spaces as space (space.id)}
<button
type="button"
class="item"
class:active={space.id === active?.id}
aria-current={space.id === active?.id ? 'true' : undefined}
onclick={() => switchTo(space.id)}
disabled={switching}
>
<span class="item-name">{space.name}</span>
<span class="type-badge" data-type={space.type}>{typeLabel(space.type)}</span>
</button>
{/each}
{/if}
<hr />
{#if active && active.type !== 'personal'}
<a class="item manage" href="/spaces/members" onclick={() => (open = false)}>
{locale === 'de' ? 'Mitglieder verwalten …' : 'Manage members …'}
</a>
{/if}
<button
type="button"
class="item create"
onclick={() => {
open = false;
createOpen = true;
}}
>
+ {locale === 'de' ? 'Neuer Space' : 'New space'}
</button>
</div>
{/if}
</div>
<div
class="menu"
role="menu"
aria-label={locale === 'de' ? 'Spaces' : 'Spaces'}
style="--menu-top: {menuPos.top}px; --menu-left: {menuPos.left}px; --menu-min-w: {menuPos.minWidth}px;"
>
{#if loadingList}
<div class="menu-empty">{locale === 'de' ? 'Lädt …' : 'Loading …'}</div>
{:else if loadError}
<div class="menu-error">{loadError}</div>
{:else if spaces.length === 0}
<div class="menu-empty">
{locale === 'de' ? 'Keine Spaces' : 'No spaces'}
</div>
{:else}
{#each spaces as space (space.id)}
<button
type="button"
class="menu-item"
class:is-active={space.id === active?.id}
aria-current={space.id === active?.id ? 'true' : undefined}
onclick={() => switchTo(space.id)}
disabled={switching}
>
<span class="type-dot" data-type={space.type} aria-hidden="true"></span>
<span class="item-name">{space.name}</span>
<span class="type-label" data-type={space.type}>{typeLabel(space.type)}</span>
</button>
{/each}
{/if}
<div class="menu-divider"></div>
{#if active && active.type !== 'personal'}
<a class="menu-item menu-link" href="/spaces/members" onclick={closeDropdown} role="menuitem">
<span class="icon-placeholder" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M23 21v-2a4 4 0 00-3-3.87M9 11a4 4 0 100-8 4 4 0 000 8zm7 0a4 4 0 00-1-7.75"
/>
</svg>
</span>
<span class="item-name">
{locale === 'de' ? 'Mitglieder verwalten' : 'Manage members'}
</span>
</a>
{/if}
<button
type="button"
class="menu-item menu-create"
onclick={() => {
closeDropdown();
createOpen = true;
}}
role="menuitem"
>
<span class="icon-placeholder" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v14M5 12h14"
/>
</svg>
</span>
<span class="item-name">
{locale === 'de' ? 'Neuer Space' : 'New space'}
</span>
</button>
</div>
{/if}
<SpaceCreateDialog bind:open={createOpen} {locale} onClose={() => (createOpen = false)} />
<style>
.space-switcher {
position: relative;
display: inline-block;
}
.trigger {
/* Trigger — mirrors @mana/shared-ui Pill (size sm) so it reads as a
native member of the PillNav row. */
.pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: 999px;
background: transparent;
border: 1px solid var(--color-border, hsl(0 0% 88%));
color: var(--color-text, inherit);
font-size: 0.8125rem;
padding: 0 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
height: 32px;
transition: background-color 120ms ease;
flex-shrink: 0;
transition: all 0.15s ease;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
box-shadow:
0 1px 2px hsl(0 0% 0% / 0.05),
0 2px 6px hsl(0 0% 0% / 0.04);
}
.trigger:hover {
background: var(--color-surface-3, hsl(0 0% 96%));
.pill-sm {
height: 36px;
}
.pill:hover {
background: hsl(var(--color-surface-hover, var(--color-card)));
border-color: hsl(var(--color-border-strong, var(--color-border)));
}
.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) .pill.active {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #f8d62b)) 30%,
transparent 70%
);
color: hsl(var(--color-foreground));
}
.name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 7rem;
max-width: 8rem;
}
.type-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: hsl(0 0% 60%);
flex-shrink: 0;
}
.type-dot[data-type='personal'] {
background: hsl(220 10% 55%);
}
.type-dot[data-type='brand'] {
background: hsl(260 60% 55%);
}
.type-dot[data-type='club'] {
background: hsl(160 55% 40%);
}
.type-dot[data-type='family'] {
background: hsl(30 75% 50%);
}
.type-dot[data-type='team'] {
background: hsl(210 60% 50%);
}
.type-dot[data-type='practice'] {
background: hsl(340 55% 50%);
}
.chev {
font-size: 0.75rem;
width: 14px;
height: 14px;
opacity: 0.6;
transition: transform 0.15s ease;
}
.type-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm, 4px);
background: var(--color-surface-3, hsl(0 0% 92%));
color: var(--color-text-muted, hsl(0 0% 45%));
text-transform: uppercase;
letter-spacing: 0.02em;
.chev.rotated {
transform: rotate(180deg);
}
.type-badge[data-type='brand'] {
background: hsl(260 70% 94%);
color: hsl(260 60% 35%);
}
.type-badge[data-type='club'] {
background: hsl(160 50% 92%);
color: hsl(160 60% 28%);
}
.type-badge[data-type='family'] {
background: hsl(30 80% 92%);
color: hsl(30 60% 35%);
}
.type-badge[data-type='team'] {
background: hsl(210 60% 92%);
color: hsl(210 60% 32%);
}
.type-badge[data-type='practice'] {
background: hsl(340 50% 92%);
color: hsl(340 55% 38%);
/* Backdrop — full-viewport click-outside catcher. Transparent but
blocks clicks from reaching elements under the menu. */
.backdrop {
position: fixed;
inset: 0;
background: transparent;
border: 0;
z-index: 1500;
cursor: default;
}
.dropdown {
position: absolute;
/* Open upward because the switcher sits inside the bottom-fixed
PillNav — a downward dropdown would land off-screen. */
bottom: calc(100% + 4px);
left: 0;
min-width: 14rem;
background: var(--color-surface-1, white);
border: 1px solid var(--color-border, hsl(0 0% 88%));
border-radius: var(--radius-md, 6px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
padding: 0.25rem;
z-index: 50;
/* Menu — escapes PillNav's overflow-x container via position:fixed.
transform: translateY(-100%) anchors the menu's bottom to
--menu-top (the 8px-offset trigger top). */
.menu {
position: fixed;
top: var(--menu-top);
left: var(--menu-left);
transform: translateY(-100%);
min-width: var(--menu-min-w);
max-width: 320px;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 12px;
box-shadow:
0 10px 30px hsl(0 0% 0% / 0.12),
0 4px 10px hsl(0 0% 0% / 0.06);
padding: 0.375rem;
z-index: 1501;
display: flex;
flex-direction: column;
gap: 2px;
animation: menu-in 120ms ease-out;
}
.dropdown hr {
border: 0;
border-top: 1px solid var(--color-border, hsl(0 0% 88%));
@keyframes menu-in {
from {
opacity: 0;
transform: translateY(-100%) translateY(4px);
}
to {
opacity: 1;
transform: translateY(-100%);
}
}
.menu-divider {
height: 1px;
background: hsl(var(--color-border));
margin: 0.25rem 0;
}
.item {
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
border-radius: var(--radius-sm, 4px);
border-radius: 8px;
background: transparent;
border: 0;
color: var(--color-text, inherit);
color: hsl(var(--color-foreground));
font-size: 0.875rem;
font-weight: 500;
text-align: left;
cursor: pointer;
text-decoration: none;
cursor: pointer;
transition: background-color 0.1s ease;
}
.item:hover:not(:disabled) {
background: var(--color-surface-2, hsl(0 0% 95%));
.menu-item:hover:not(:disabled) {
background: hsl(var(--color-surface-hover, var(--color-muted, 0 0% 96%)));
}
.item:disabled {
opacity: 0.6;
.menu-item:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.item.active {
background: var(--color-surface-2, hsl(0 0% 95%));
font-weight: 600;
.menu-item.is-active {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #f8d62b)) 15%,
transparent 85%
);
}
.item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item.create {
color: var(--color-primary, hsl(230 80% 50%));
.type-label {
font-size: 0.6875rem;
font-weight: 500;
padding: 2px 8px;
border-radius: 9999px;
background: hsl(var(--color-muted, 0 0% 94%));
color: hsl(var(--color-muted-foreground, 0 0% 40%));
text-transform: uppercase;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.item.manage {
color: var(--color-text-muted, hsl(0 0% 45%));
.type-label[data-type='brand'] {
background: hsl(260 60% 95%);
color: hsl(260 55% 35%);
}
.type-label[data-type='club'] {
background: hsl(160 45% 93%);
color: hsl(160 55% 28%);
}
.type-label[data-type='family'] {
background: hsl(30 70% 93%);
color: hsl(30 55% 35%);
}
.type-label[data-type='team'] {
background: hsl(210 55% 93%);
color: hsl(210 55% 32%);
}
.type-label[data-type='practice'] {
background: hsl(340 50% 94%);
color: hsl(340 50% 38%);
}
.empty,
.error {
:global(.dark) .type-label {
background: hsl(var(--color-muted, 0 0% 20%));
color: hsl(var(--color-muted-foreground, 0 0% 75%));
}
:global(.dark) .type-label[data-type='brand'] {
background: hsl(260 40% 25%);
color: hsl(260 80% 85%);
}
:global(.dark) .type-label[data-type='club'] {
background: hsl(160 35% 20%);
color: hsl(160 70% 80%);
}
:global(.dark) .type-label[data-type='family'] {
background: hsl(30 40% 22%);
color: hsl(30 80% 82%);
}
:global(.dark) .type-label[data-type='team'] {
background: hsl(210 40% 22%);
color: hsl(210 70% 82%);
}
:global(.dark) .type-label[data-type='practice'] {
background: hsl(340 35% 23%);
color: hsl(340 65% 82%);
}
.icon-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: hsl(var(--color-muted-foreground, 0 0% 45%));
flex-shrink: 0;
}
.menu-create {
color: hsl(var(--color-primary, var(--color-primary-500, 230 80% 50%)));
}
.menu-create .icon-placeholder {
color: inherit;
}
.menu-empty,
.menu-error {
padding: 0.5rem 0.625rem;
font-size: 0.8125rem;
color: var(--color-text-muted, hsl(0 0% 45%));
color: hsl(var(--color-muted-foreground, 0 0% 45%));
}
.error {
color: hsl(0 60% 45%);
.menu-error {
color: hsl(0 65% 50%);
}
</style>