feat: unified glass material system for all floating surfaces

Add .glass-modal CSS class (0.82 opacity) for dialogs and apply
consistent glass tiers across all floating UI: modals, context menus,
tooltips, popovers, and call cards. Replaces ad-hoc bg-surface-elevated
and inline styles with the documented 5-tier glass hierarchy.
This commit is contained in:
Jannis Braun
2026-03-11 22:48:11 +01:00
parent 7572f165de
commit 87e3d34638
12 changed files with 190 additions and 34 deletions
+17
View File
@@ -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.
@@ -58,7 +58,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
return createPortal(
<div ref={floatingRef} style={style} className="w-[280px]">
<div className="bg-surface-elevated rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.15),0_8px_16px_rgba(0,0,0,0.24)] overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
<div className="glass rounded-lg overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
Members
</div>
@@ -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(
<div
ref={menuRef}
className="fixed z-[9999] min-w-[160px] bg-surface-overlay rounded-lg border border-white/[0.07] shadow-lg py-1 animate-in fade-in zoom-in-95 duration-100"
className="fixed z-[9999] min-w-[160px] glass rounded-lg py-1 animate-in fade-in zoom-in-95 duration-100"
style={{ left: clampedX, top: clampedY }}
>
<button
@@ -260,15 +262,7 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb
) : (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-accent-rose hover:bg-accent-rose/10 transition-colors"
onClick={() => {
if (currentSpaceId === spaceId) {
navigate('/channels/@me');
setCurrentSpace(null);
setShowDms(true);
}
leaveSpace(spaceId);
onClose();
}}
onClick={() => setShowLeaveConfirm(true)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M10.09 15.59L11.5 17l5-5-5-5-1.41 1.41L12.67 11H3v2h9.67l-2.58 2.59zM19 3H5a2 2 0 00-2 2v4h2V5h14v14H5v-4H3v4a2 2 0 002 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
@@ -276,6 +270,24 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb
Leave Space
</button>
)}
<ConfirmDialog
isOpen={showLeaveConfirm}
onClose={() => 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"
/>
</div>,
document.body,
);
@@ -351,7 +363,7 @@ function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
<div
ref={modalRef}
className="w-[380px] max-h-[480px] bg-surface-overlay rounded-xl border border-white/[0.07] shadow-2xl flex flex-col animate-in fade-in zoom-in-95 duration-150"
className="w-[380px] max-h-[480px] glass-modal rounded-xl flex flex-col animate-in fade-in zoom-in-95 duration-150"
>
{/* Header */}
<div className="px-4 pt-4 pb-3 border-b border-white/[0.06]">
@@ -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<Map<string, Set<string>>>(new Map());
const [expandedMemberId, setExpandedMemberId] = useState<string | null>(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) {
<div className="flex items-center gap-1 flex-shrink-0">
{canBan && member.userId !== currentUser?.id && !isOwner && (
<button
onClick={(e) => { e.stopPropagation(); handleBan(member.userId); }}
onClick={(e) => { e.stopPropagation(); setPendingAction({ type: 'ban', userId: member.userId, displayName }); }}
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
>
Ban
@@ -178,7 +180,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
)}
{canKick && member.userId !== currentUser?.id && !isOwner && (
<button
onClick={(e) => { e.stopPropagation(); handleKick(member.userId); }}
onClick={(e) => { e.stopPropagation(); setPendingAction({ type: 'kick', userId: member.userId, displayName }); }}
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
>
Kick
@@ -241,6 +243,28 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
</div>
</div>
</div>
<ConfirmDialog
isOpen={pendingAction !== null}
onClose={() => 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'}
/>
</div>
);
}
@@ -0,0 +1,80 @@
import React, { useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
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(
<div className="fixed inset-0 z-[10000] flex items-center justify-center animate-fade-in">
<div
className="absolute inset-0 bg-black/50"
onClick={() => { if (!loading) onClose(); }}
/>
<div className="relative max-w-sm w-full mx-4 glass-modal rounded-lg animate-slide-up">
<div className="p-4">
<h3 className="text-base font-semibold text-txt-primary mb-3">{title}</h3>
<div className={`p-3 rounded-lg ${accentBg} border ${accentBorder} mb-4`}>
<div className="text-sm text-txt-secondary">{description}</div>
</div>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
disabled={loading}
className="px-3 py-1.5 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
disabled={loading}
className={`px-3 py-1.5 ${confirmBg} text-white text-sm font-medium rounded transition-colors disabled:opacity-50`}
>
{loading ? 'Please wait...' : confirmLabel}
</button>
</div>
</div>
</div>
</div>,
document.body,
);
}
@@ -64,7 +64,7 @@ export function ContextMenu({ items, children }: ContextMenuProps) {
{isOpen && (
<div
ref={menuRef}
className="fixed z-[200] min-w-[180px] py-1.5 bg-surface-elevated rounded-md shadow-elevation-high animate-fade-in max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin"
className="fixed z-[200] min-w-[180px] py-1.5 glass rounded-md animate-fade-in max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin"
style={{ left: position.x, top: position.y }}
>
{items.map((item, i) => (
+2 -2
View File
@@ -27,10 +27,10 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md'
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center animate-fade-in">
<div
className="absolute inset-0 bg-surface-overlay"
className="absolute inset-0 bg-black/50"
onClick={onClose}
/>
<div className={`relative ${maxWidth} w-full mx-4 max-h-[calc(100vh-2rem)] flex flex-col bg-surface-elevated rounded-lg shadow-xl animate-slide-up`}>
<div className={`relative ${maxWidth} w-full mx-4 max-h-[calc(100vh-2rem)] flex flex-col glass-modal rounded-lg animate-slide-up`}>
{title && (
<div className="flex items-center justify-between px-4 pt-4 flex-shrink-0">
<h2 className="text-xl font-bold text-txt-primary">{title}</h2>
+1 -1
View File
@@ -43,7 +43,7 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
<div
ref={floatingRef}
style={style}
className="px-3 py-1.5 text-sm font-medium text-txt-primary bg-surface-elevated rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none"
className="px-3 py-1.5 text-sm font-medium text-txt-primary glass rounded-md whitespace-nowrap pointer-events-none"
>
{content}
</div>,
@@ -79,16 +79,10 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
return (
<div
className="fixed z-[200] w-[340px] rounded-[12px] overflow-hidden animate-fade-in select-none border border-white/[0.07]"
style={{
...(position
? { top, left }
: { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }),
backdropFilter: 'blur(20px) saturate(1.2)',
WebkitBackdropFilter: 'blur(20px) saturate(1.2)',
backgroundColor: 'rgba(20,20,26,0.85)',
boxShadow: '0 8px 32px rgba(0,0,0,0.45), 0 2px 8px rgba(0,0,0,0.25)',
}}
className="fixed z-[200] w-[340px] rounded-[12px] overflow-hidden animate-fade-in select-none glass-modal"
style={position
? { top, left }
: { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}
>
{/* Banner */}
<div
@@ -51,10 +51,10 @@ export function IncomingCallModal() {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60" />
<div className="absolute inset-0 bg-black/50" />
{/* Call card */}
<div className="relative bg-surface-channel rounded-lg shadow-2xl w-[340px] overflow-hidden">
<div className="relative glass-modal rounded-lg w-[340px] overflow-hidden">
{/* Ring animation background */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200px] h-[200px] rounded-full bg-status-online/5 animate-ping" style={{ animationDuration: '2s' }} />
@@ -192,7 +192,7 @@ function MoveToSubmenu({ channels, onMove, btnClass, btnStyle }: MoveToSubmenuPr
{open && ReactDOM.createPortal(
<div
ref={flyoutRef}
className="fixed z-[210] bg-surface-elevated rounded-md shadow-elevation-high py-1.5 min-w-[160px] overflow-y-auto scrollbar-thin animate-fade-in"
className="fixed z-[210] glass rounded-md py-1.5 min-w-[160px] overflow-y-auto scrollbar-thin animate-fade-in"
style={{ left: -9999, top: -9999 }}
onMouseEnter={cancelCloseTimer}
onMouseLeave={startCloseTimer}
@@ -291,7 +291,7 @@ export function VoiceUserContextMenu({ targetUserId, channelId, position, onClos
return ReactDOM.createPortal(
<div
ref={menuRef}
className="fixed z-[200] bg-surface-elevated rounded-md shadow-elevation-high min-w-[200px] max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin animate-fade-in"
className="fixed z-[200] glass rounded-md min-w-[200px] max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin animate-fade-in"
style={{ left: position.x, top: position.y }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
+29
View File
@@ -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;