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; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.4); 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; border: 0;
} }
@ -235,11 +237,12 @@
width: min(540px, 92vw); width: min(540px, 92vw);
max-height: 86vh; max-height: 86vh;
overflow-y: auto; overflow-y: auto;
background: var(--color-surface-1, white); background: hsl(var(--color-card, 0 0% 100%));
color: var(--color-text, inherit); color: hsl(var(--color-foreground, 0 0% 10%));
border-radius: var(--radius-lg, 10px); border: 1px solid hsl(var(--color-border, 0 0% 88%));
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
z-index: 201; z-index: 1601;
} }
form { form {

View file

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