diff --git a/CLAUDE.md b/CLAUDE.md index 4dd47ed8..94ae0023 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,23 @@ Backspace has its own visual identity — it is NOT a Discord clone. The design When making UI changes, consult the prototype for colors, spacing, materials, and hierarchy. The frontend should converge toward this design. +### Surface Material Tiers + +Every surface in Backspace falls into one of these tiers: + +| Tier | Class | When to Use | +|------|-------|-------------| +| Structural | `bg-surface-*` | Permanent layout (sidebars, chat area, member list) | +| Strip | `.glass-strip` | Persistent edge chrome (space sidebar) | +| Bubble | `.glass-bubble` | Persistent floating controls (voice bar, input pill, sticky actions) | +| Popover | `.glass` | Small floating surfaces (context menus, popovers, autocomplete, tooltips) | +| Modal | `.glass-modal` | Large center-screen dialogs with backdrop scrim | +| Pill | `.glass-pill` | Tiny inline decorations (reactions, tags) | + +**Rule:** If it floats above the content plane, it's glass. Never use `bg-surface-elevated` for floating/overlay elements — that's for static structural panels only. + +**Modal backdrops** use `bg-black/50` — light enough for the glass card's blur to show through. + ## MISSION Maintain and extend Backspace as a complete, production-quality application. The core application is fully built and deployed. Every change must uphold the same standard: no stubs, no TODOs, no shortcuts. A user must always be able to `docker compose up` and have a fully working chat platform. diff --git a/packages/web/src/components/chat/MentionPopover.tsx b/packages/web/src/components/chat/MentionPopover.tsx index eae27b68..592ebfcd 100644 --- a/packages/web/src/components/chat/MentionPopover.tsx +++ b/packages/web/src/components/chat/MentionPopover.tsx @@ -58,7 +58,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me return createPortal(
-
+
Members
diff --git a/packages/web/src/components/layout/SpaceSidebar.tsx b/packages/web/src/components/layout/SpaceSidebar.tsx index 3050ecee..b6a40d19 100644 --- a/packages/web/src/components/layout/SpaceSidebar.tsx +++ b/packages/web/src/components/layout/SpaceSidebar.tsx @@ -7,6 +7,7 @@ import { useUIStore } from '../../stores/uiStore'; import { useInstanceStore } from '../../stores/instanceStore'; import { useAuthStore } from '../../stores/authStore'; import { Tooltip } from '../ui/Tooltip'; +import { ConfirmDialog } from '../ui/ConfirmDialog'; import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients'; @@ -176,12 +177,13 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb const addToast = useUIStore((s) => s.addToast); const navigate = useNavigate(); const [showTransferModal, setShowTransferModal] = useState(false); + const [showLeaveConfirm, setShowLeaveConfirm] = useState(false); const isOwner = space?.ownerId === getMyUserIdForOrigin((space as any)?._instanceOrigin ?? ''); // Close on click-outside and scroll useEffect(() => { - if (showTransferModal) return; // Don't close when transfer modal is open + if (showTransferModal || showLeaveConfirm) return; // Don't close when sub-dialog is open const handleClickOutside = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { onClose(); @@ -200,7 +202,7 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb document.removeEventListener('scroll', handleScroll, true); document.removeEventListener('keydown', handleKeyDown); }; - }, [onClose, showTransferModal]); + }, [onClose, showTransferModal, showLeaveConfirm]); if (!space) return null; @@ -235,7 +237,7 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb return ReactDOM.createPortal(
)} + setShowLeaveConfirm(false)} + onConfirm={() => { + if (currentSpaceId === spaceId) { + navigate('/channels/@me'); + setCurrentSpace(null); + setShowDms(true); + } + leaveSpace(spaceId); + setShowLeaveConfirm(false); + onClose(); + }} + title={`Leave ${space.name}`} + description="Are you sure you want to leave this space? You'll need a new invite to rejoin." + variant="danger" + confirmLabel="Leave" + />
, document.body, ); @@ -351,7 +363,7 @@ function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose
{/* Header */}
diff --git a/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx index 3c0fce6b..cb5430c5 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { Avatar } from '../../ui/Avatar'; +import { ConfirmDialog } from '../../ui/ConfirmDialog'; import { useSpaceStore, getApiForOrigin } from '../../../stores/spaceStore'; import { useAuthStore } from '../../../stores/authStore'; import { parseFederatedUsername } from '../../../utils/identity'; @@ -28,6 +29,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) { const [pendingRoleChanges, setPendingRoleChanges] = useState>>(new Map()); const [expandedMemberId, setExpandedMemberId] = useState(null); const [error, setError] = useState(''); + const [pendingAction, setPendingAction] = useState<{ type: 'kick' | 'ban'; userId: string; displayName: string } | null>(null); // Assignable roles: exclude @everyone (where role.id === spaceId) const assignableRoles = roles.filter((r) => r.id !== spaceId); @@ -170,7 +172,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
{canBan && member.userId !== currentUser?.id && !isOwner && (
+ + setPendingAction(null)} + onConfirm={async () => { + if (!pendingAction) return; + if (pendingAction.type === 'kick') { + await handleKick(pendingAction.userId); + } else { + await handleBan(pendingAction.userId); + } + setPendingAction(null); + }} + title={pendingAction?.type === 'ban' ? `Ban ${pendingAction.displayName}` : `Kick ${pendingAction?.displayName ?? ''}`} + description={ + pendingAction?.type === 'ban' + ? 'They will be permanently banned from this space until unbanned.' + : 'They can rejoin with an invite link.' + } + variant={pendingAction?.type === 'ban' ? 'danger' : 'warning'} + confirmLabel={pendingAction?.type === 'ban' ? 'Ban' : 'Kick'} + />
); } diff --git a/packages/web/src/components/ui/ConfirmDialog.tsx b/packages/web/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 00000000..ec842b56 --- /dev/null +++ b/packages/web/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useCallback } from 'react'; +import ReactDOM from 'react-dom'; + +interface ConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void | Promise; + title: string; + description: string | React.ReactNode; + confirmLabel?: string; + cancelLabel?: string; + variant?: 'danger' | 'warning'; + loading?: boolean; +} + +export function ConfirmDialog({ + isOpen, + onClose, + onConfirm, + title, + description, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + variant = 'danger', + loading = false, +}: ConfirmDialogProps) { + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape' && !loading) onClose(); + }, [onClose, loading]); + + useEffect(() => { + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + } + }, [isOpen, handleKeyDown]); + + if (!isOpen) return null; + + const isDanger = variant === 'danger'; + const accentBg = isDanger ? 'bg-accent-rose/10' : 'bg-accent-amber/10'; + const accentBorder = isDanger ? 'border-accent-rose/20' : 'border-accent-amber/20'; + const confirmBg = isDanger + ? 'bg-accent-rose hover:bg-accent-rose/80' + : 'bg-accent-amber hover:bg-accent-amber/80'; + + return ReactDOM.createPortal( +
+
{ if (!loading) onClose(); }} + /> +
+
+

{title}

+
+
{description}
+
+
+ + +
+
+
+
, + document.body, + ); +} diff --git a/packages/web/src/components/ui/ContextMenu.tsx b/packages/web/src/components/ui/ContextMenu.tsx index 28871def..7ffff9ab 100644 --- a/packages/web/src/components/ui/ContextMenu.tsx +++ b/packages/web/src/components/ui/ContextMenu.tsx @@ -64,7 +64,7 @@ export function ContextMenu({ items, children }: ContextMenuProps) { {isOpen && (
{items.map((item, i) => ( diff --git a/packages/web/src/components/ui/Modal.tsx b/packages/web/src/components/ui/Modal.tsx index 1f648811..c010b36f 100644 --- a/packages/web/src/components/ui/Modal.tsx +++ b/packages/web/src/components/ui/Modal.tsx @@ -27,10 +27,10 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' return (
-
+
{title && (

{title}

diff --git a/packages/web/src/components/ui/Tooltip.tsx b/packages/web/src/components/ui/Tooltip.tsx index b7387899..dd48cb48 100644 --- a/packages/web/src/components/ui/Tooltip.tsx +++ b/packages/web/src/components/ui/Tooltip.tsx @@ -43,7 +43,7 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
{content}
, diff --git a/packages/web/src/components/ui/UserProfilePopout.tsx b/packages/web/src/components/ui/UserProfilePopout.tsx index 79b6bfd1..2432d5a7 100644 --- a/packages/web/src/components/ui/UserProfilePopout.tsx +++ b/packages/web/src/components/ui/UserProfilePopout.tsx @@ -79,16 +79,10 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout return (
{/* Banner */}
{/* Backdrop */} -
+
{/* Call card */} -
+
{/* Ring animation background */}
diff --git a/packages/web/src/components/voice/VoiceUserContextMenu.tsx b/packages/web/src/components/voice/VoiceUserContextMenu.tsx index 004212b8..d9c1bf4b 100644 --- a/packages/web/src/components/voice/VoiceUserContextMenu.tsx +++ b/packages/web/src/components/voice/VoiceUserContextMenu.tsx @@ -192,7 +192,7 @@ function MoveToSubmenu({ channels, onMove, btnClass, btnStyle }: MoveToSubmenuPr {open && ReactDOM.createPortal(
e.stopPropagation()} onClick={(e) => e.stopPropagation()} diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css index 04f9cc25..df47e106 100644 --- a/packages/web/src/styles/globals.css +++ b/packages/web/src/styles/globals.css @@ -56,6 +56,7 @@ /* ── Glass Material ── */ --glass-bg: rgba(20, 20, 26, 0.52); + --glass-modal-bg: rgba(20, 20, 26, 0.82); --glass-border: rgba(255, 255, 255, 0.07); --glass-highlight: rgba(255, 255, 255, 0.05); @@ -117,6 +118,17 @@ } @layer components { + /* ── Glass Material Tiers ── + * .glass — Popovers, context menus, autocomplete (small, no backdrop) + * .glass-modal — Center-screen dialogs (large, with backdrop, higher opacity for legibility) + * .glass-strip — Space sidebar edge (no border-radius, directional shadow) + * .glass-bubble — Persistent floating controls (voice bar, input pill) + * .glass-pill — Inline decorations (reactions, tags, lighter blur) + * + * RULE: Every floating surface uses a glass tier. Never use bg-surface-elevated for overlays. + * See CLAUDE.md "Surface Material Tiers" for the full decision guide. + */ + /* Standard glass — popovers, floating elements */ .glass { backdrop-filter: blur(20px) saturate(120%); @@ -153,6 +165,18 @@ inset 0 1px 0 var(--glass-highlight); } + /* Modal glass — center-screen dialogs with backdrop */ + .glass-modal { + backdrop-filter: blur(20px) saturate(120%); + -webkit-backdrop-filter: blur(20px) saturate(120%); + background: var(--glass-modal-bg); + border: 1px solid var(--glass-border); + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.30), + 0 12px 40px rgba(0, 0, 0, 0.20), + inset 0 1px 0 var(--glass-highlight); + } + /* Reaction pill glass — lighter blur for small inline elements */ .glass-pill { backdrop-filter: blur(12px) saturate(110%); @@ -193,6 +217,11 @@ -webkit-backdrop-filter: none; background: rgb(var(--bg-elevated)); } + .glass-modal { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: rgb(var(--bg-elevated)); + } .glass-pill { backdrop-filter: none; -webkit-backdrop-filter: none;