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:
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user