wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)

6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.)
als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen
Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
This commit is contained in:
Bernd Steckmeister
2026-07-03 05:47:18 +02:00
parent d706ace35f
commit 25ed765a03
195 changed files with 47784 additions and 1236 deletions
+478
View File
@@ -0,0 +1,478 @@
/* Pyramid · main app */
const ACCENT_PRESETS = [
{ name: 'amber', h: 42, s: 95, l: 58 },
{ name: 'coral', h: 12, s: 85, l: 62 },
{ name: 'mint', h: 160, s: 65, l: 50 },
{ name: 'violet', h: 268, s: 78, l: 66 },
{ name: 'cobalt', h: 220, s: 85, l: 62 },
{ name: 'lime', h: 80, s: 70, l: 55 },
];
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accentIdx": 0,
"theme": "dark",
"density": 1,
"motion": 1,
"radius": 12,
"view": "chat"
}/*EDITMODE-END*/;
const loadTweak = (k, d) => {
try { const v = localStorage.getItem('pyr_' + k); return v === null ? d : JSON.parse(v); } catch { return d; }
};
const saveTweak = (k, v) => { try { localStorage.setItem('pyr_' + k, JSON.stringify(v)); } catch {} };
const DEFAULT_CALL_PARTICIPANTS = [
{ name: 'juno', avatar: 'J', color: '#06b6d4', speaking: true, muted: false, cam: false },
{ name: 'sachi', avatar: 'S', color: '#a855f7', speaking: false, muted: false, cam: false },
{ name: 'mira (you)', avatar: 'M', color: '#ff6b9d', speaking: false, muted: false, cam: false, self: true },
{ name: 'ren', avatar: 'R', color: '#10b981', speaking: false, muted: true, cam: false },
];
const App = () => {
const [theme, setTheme] = React.useState(() => loadTweak('theme', TWEAK_DEFAULTS.theme));
const [accentIdx, setAccentIdx] = React.useState(() => loadTweak('accentIdx', TWEAK_DEFAULTS.accentIdx));
const [density, setDensity] = React.useState(() => loadTweak('density', TWEAK_DEFAULTS.density));
const [motion, setMotion] = React.useState(() => loadTweak('motion', TWEAK_DEFAULTS.motion));
const [radius, setRadius] = React.useState(() => loadTweak('radius', TWEAK_DEFAULTS.radius));
const [loggedIn, setLoggedIn] = React.useState(() => loadTweak('loggedIn', true));
const [view, setView] = React.useState(() => {
// never land on voice without an active call
const v = loadTweak('view', 'chat');
return v === 'voice' ? 'chat' : v;
});
const [activeSpace, setActiveSpace] = React.useState(() => loadTweak('space', 'atelier'));
const [activeRoom, setActiveRoom] = React.useState(() => loadTweak('room', 'general'));
const [railCollapsed, setRailCollapsed] = React.useState(false);
const [membersOpen, setMembersOpen] = React.useState(true);
const [tweaksMode, setTweaksMode] = React.useState(false);
const [showSettings, setShowSettings] = React.useState(false);
const [showSearch, setShowSearch] = React.useState(false);
const [profilePopover, setProfilePopover] = React.useState(null);
const [emojiPickerAt, setEmojiPickerAt] = React.useState(null);
const [messageActions, setMessageActions] = React.useState(null);
const [toast, setToast] = React.useState(null);
const [messagesState, setMessagesState] = React.useState(() => window.PYRAMID_DATA.messagesByRoom);
/* call state — persists across navigation */
const [call, setCall] = React.useState(null);
const [callReturnRoom, setCallReturnRoom] = React.useState(null);
React.useEffect(() => { saveTweak('theme', theme); }, [theme]);
React.useEffect(() => { saveTweak('accentIdx', accentIdx); }, [accentIdx]);
React.useEffect(() => { saveTweak('density', density); }, [density]);
React.useEffect(() => { saveTweak('motion', motion); }, [motion]);
React.useEffect(() => { saveTweak('radius', radius); }, [radius]);
React.useEffect(() => { saveTweak('view', view); }, [view]);
React.useEffect(() => { saveTweak('space', activeSpace); }, [activeSpace]);
React.useEffect(() => { saveTweak('room', activeRoom); }, [activeRoom]);
React.useEffect(() => { saveTweak('loggedIn', loggedIn); }, [loggedIn]);
React.useEffect(() => {
const a = ACCENT_PRESETS[accentIdx];
const r = document.documentElement;
r.setAttribute('data-theme', theme);
r.style.setProperty('--accent-h', a.h);
r.style.setProperty('--accent-s', a.s + '%');
r.style.setProperty('--accent-l', a.l + '%');
r.style.setProperty('--density', density);
r.style.setProperty('--motion', motion);
r.style.setProperty('--r-base', radius + 'px');
}, [theme, accentIdx, density, motion, radius]);
React.useEffect(() => {
const handler = (e) => {
if (e.data?.type === '__activate_edit_mode') setTweaksMode(true);
if (e.data?.type === '__deactivate_edit_mode') setTweaksMode(false);
};
window.addEventListener('message', handler);
window.parent?.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', handler);
}, []);
const pushEdits = (edits) => {
try { window.parent?.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); } catch {}
};
React.useEffect(() => {
const h = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setShowSearch(true); }
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') { e.preventDefault(); setRailCollapsed(v => !v); }
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'u') { e.preventDefault(); setMembersOpen(v => !v); }
if (e.key === 'Escape') {
setShowSearch(false); setShowSettings(false);
setProfilePopover(null); setEmojiPickerAt(null); setMessageActions(null);
}
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, []);
const showToast = (msg, icon = 'check') => {
setToast({ msg, icon });
setTimeout(() => setToast(null), 2400);
};
const handleSelectSpace = (spaceId) => {
setActiveSpace(spaceId);
setView('chat');
const data = window.PYRAMID_DATA.rooms[spaceId];
if (data) {
const firstActive = data.sections.flatMap(s => s.rooms).find(r => r.active) || data.sections[0].rooms[0];
setActiveRoom(firstActive.id);
}
};
const handleSelectRoom = (roomId) => {
setActiveRoom(roomId);
const data = window.PYRAMID_DATA.rooms[activeSpace];
const roomDef = data?.sections.flatMap(s => s.rooms).find(r => r.id === roomId);
if (roomDef?.type === 'voice') {
// joining a voice room starts a call for that room
startCall({ roomName: roomDef.name, kind: 'voice', room: roomDef });
setView('voice');
} else {
setView('chat');
}
};
const startCall = ({ roomName, kind, room }) => {
setCall({
active: true,
roomName,
kind,
participants: DEFAULT_CALL_PARTICIPANTS,
mic: true, cam: kind === 'video', deaf: false,
streaming: false, streamCollapsed: false,
streamer: 'mira (you)',
roomId: room?.id || null,
});
setCallReturnRoom({ space: activeSpace, room: activeRoom });
};
/* called from DM header */
React.useEffect(() => {
window.__startDMCall = (roomDef, kind) => {
startCall({ roomName: roomDef?.name || 'DM', kind, room: roomDef });
setView('voice');
};
return () => { window.__startDMCall = null; };
}, [activeSpace, activeRoom]);
const handleLeaveCall = () => {
setCall(null);
if (view === 'voice') {
if (callReturnRoom) {
setActiveSpace(callReturnRoom.space);
setActiveRoom(callReturnRoom.room);
}
setView('chat');
}
setCallReturnRoom(null);
showToast('left the call', 'phoneOff');
};
const updateCall = (patch) => setCall(c => c ? { ...c, ...patch } : c);
const handleJumpToCall = () => {
setView('voice');
};
const handleSend = (text) => {
const newMsg = {
id: 'm' + Date.now(),
author: 'mira',
avatar: 'M',
color: '#ff6b9d',
time: new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
text,
};
setMessagesState(prev => ({
...prev,
[activeRoom]: [...(prev[activeRoom] || []), newMsg],
}));
};
const handleReact = (msgId, emoji) => {
setMessagesState(prev => {
const list = [...(prev[activeRoom] || [])];
const idx = list.findIndex(m => m.id === msgId);
if (idx < 0) return prev;
const msg = { ...list[idx] };
const reactions = [...(msg.reactions || [])];
const existing = reactions.findIndex(r => r.emoji === emoji);
if (existing >= 0) {
const r = { ...reactions[existing] };
if (r.mine) { r.mine = false; r.count--; } else { r.mine = true; r.count++; }
if (r.count <= 0) reactions.splice(existing, 1);
else reactions[existing] = r;
} else {
reactions.push({ emoji, count: 1, mine: true });
}
msg.reactions = reactions;
list[idx] = msg;
return { ...prev, [activeRoom]: list };
});
};
const handleOpenProfile = (name, e) => {
const rect = e?.currentTarget?.getBoundingClientRect();
const profiles = {
juno: { name: 'juno', handle: '@juno:pyramid.chat', avatar: 'J', color: '#06b6d4', status: 'online', about: 'risograph, zines, tea. teaches at the atelier on tuesdays.', roles: [{ name: 'founder', color: '#f59e0b' }, { name: 'print-nerd', color: '#06b6d4' }] },
sachi: { name: 'sachi', handle: '@sachi:pyramid.chat', avatar: 'S', color: '#a855f7', status: 'online', about: 'illustration, collage, usually reading', roles: [{ name: 'mod', color: '#a855f7' }] },
ren: { name: 'ren', handle: '@ren:pyramid.chat', avatar: 'R', color: '#10b981', status: 'busy', about: 'graphic design studio of one', roles: [] },
kai: { name: 'kai', handle: '@kai:pyramid.chat', avatar: 'K', color: '#f43f5e', status: 'away', about: '3d + motion. fancy renders upon request.', roles: [] },
ezra: { name: 'ezra', handle: '@ezra:pyramid.chat', avatar: 'E', color: '#3b82f6', status: 'online', about: 'keeps the lights on. dev-lab lurker.', roles: [{ name: 'dev', color: '#3b82f6' }] },
mira: { name: 'mira', handle: '@mira:pyramid.chat', avatar: 'M', color: '#ff6b9d', status: 'online', about: 'painter · printmaker · pastry enthusiast', roles: [{ name: 'founder', color: '#f59e0b' }] },
};
setProfilePopover({ anchor: rect, profile: profiles[name] || profiles.juno });
};
const handleOpenEmoji = (target, e) => {
const rect = e?.currentTarget?.getBoundingClientRect();
setEmojiPickerAt({ target, rect });
};
const handlePickEmoji = (emoji) => {
if (emojiPickerAt?.target === 'composer') {
showToast(`${emoji} added`, 'check');
} else if (emojiPickerAt?.target) {
handleReact(emojiPickerAt.target, emoji);
}
};
const handleOpenActions = (msgId, e) => {
const rect = e?.currentTarget?.getBoundingClientRect();
setMessageActions({ msgId, rect });
};
const handleMsgAction = (action) => {
const labels = {
reply: 'replying…', thread: 'thread opened', edit: 'editing message',
pin: 'pinned to room', bookmark: 'saved for later',
copy: 'copied', link: 'message link copied', delete: 'message deleted',
};
showToast(labels[action] || action);
};
if (!loggedIn) {
return <LoginScreen onLogin={() => setLoggedIn(true)} />;
}
const spaces = window.PYRAMID_DATA.spaces;
const roomsData = window.PYRAMID_DATA.rooms[activeSpace] || window.PYRAMID_DATA.rooms.atelier;
const messages = messagesState[activeRoom] || [];
const members = window.PYRAMID_DATA.membersByRoom[activeRoom] || window.PYRAMID_DATA.membersByRoom.general;
const showMembers = membersOpen && view !== 'voice';
const appClasses = [
'app',
railCollapsed && 'rail-collapsed',
showMembers && 'right-open',
].filter(Boolean).join(' ');
return (
<>
<div className={appClasses} data-screen-label={view === 'voice' ? 'Voice Channel' : activeRoom}>
<SpacesRail
spaces={spaces}
activeSpace={activeSpace}
onSelect={handleSelectSpace}
collapsed={railCollapsed}
/>
<RoomsPanel
space={activeSpace}
roomsData={roomsData}
activeRoom={activeRoom}
onSelectRoom={handleSelectRoom}
user={window.PYRAMID_DATA.user}
onOpenProfile={(e) => handleOpenProfile('mira', e)}
onOpenSettings={() => setShowSettings(true)}
call={call}
onLeaveCall={handleLeaveCall}
onToggleStreamCollapse={() => call && updateCall({ streamCollapsed: !call.streamCollapsed })}
onToggleMic={() => call && updateCall({ mic: !call.mic })}
onToggleDeaf={() => call && updateCall({ deaf: !call.deaf })}
onToggleScreen={() => call && updateCall({ streaming: !call.streaming, streamCollapsed: false })}
onToggleCam={() => call && updateCall({ cam: !call.cam })}
onJumpToCall={handleJumpToCall}
/>
{view === 'voice' ? (
<VoiceChannel
call={call}
onLeave={handleLeaveCall}
onToggleRail={() => setRailCollapsed(v => !v)}
onOpenProfile={handleOpenProfile}
onToggleMic={() => call && updateCall({ mic: !call.mic })}
onToggleDeaf={() => call && updateCall({ deaf: !call.deaf })}
onToggleCam={() => call && updateCall({ cam: !call.cam })}
onToggleScreen={() => call && updateCall({ streaming: !call.streaming, streamCollapsed: false })}
/>
) : (
<ChatView
room={activeRoom}
roomsData={roomsData}
messages={messages}
members={members}
onOpenProfile={handleOpenProfile}
onOpenActions={handleOpenActions}
onOpenEmoji={handleOpenEmoji}
onToggleMembers={() => setMembersOpen(v => !v)}
membersOpen={membersOpen}
onOpenSearch={() => setShowSearch(true)}
onToggleRail={() => setRailCollapsed(v => !v)}
railCollapsed={railCollapsed}
onSend={handleSend}
onReact={handleReact}
/>
)}
{showMembers && <MembersPanel room={activeRoom} members={members} />}
</div>
<DemoSwitcher
view={view}
setView={(v) => {
if (v === 'voice') {
if (!call) startCall({ roomName: 'The Lounge', kind: 'voice', room: { id: 'lounge', name: 'The Lounge' } });
setView('voice');
}
else if (v === 'dm') { setActiveSpace('friends'); setActiveRoom('dm-juno'); setView('chat'); }
else if (v === 'chat') { setActiveSpace('atelier'); setActiveRoom('general'); setView('chat'); }
}}
onLogin={() => setLoggedIn(false)}
onSearch={() => setShowSearch(true)}
onSettings={() => setShowSettings(true)}
onProfile={(e) => handleOpenProfile('juno', e)}
onEmoji={(e) => handleOpenEmoji('composer', e)}
onTheme={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
theme={theme}
/>
{showSettings && (
<SettingsModal
onClose={() => setShowSettings(false)}
theme={theme}
onTheme={setTheme}
accentPresets={ACCENT_PRESETS}
accent={ACCENT_PRESETS[accentIdx].h}
onAccent={(p) => setAccentIdx(ACCENT_PRESETS.findIndex(a => a.name === p.name))}
density={density}
onDensity={setDensity}
motion={motion}
onMotion={setMotion}
radius={radius}
onRadius={setRadius}
/>
)}
{showSearch && <SearchModal onClose={() => setShowSearch(false)} onJump={() => setShowSearch(false)} />}
{profilePopover && (
<ProfilePopover
anchor={profilePopover.anchor}
profile={profilePopover.profile}
onClose={() => setProfilePopover(null)}
/>
)}
{emojiPickerAt && (
<EmojiPicker
anchor={emojiPickerAt.rect}
onPick={handlePickEmoji}
onClose={() => setEmojiPickerAt(null)}
/>
)}
{messageActions && (
<MessageActionsMenu
anchor={messageActions.rect}
onAction={handleMsgAction}
onClose={() => setMessageActions(null)}
/>
)}
{toast && (
<div className="toast">
<Icon name={toast.icon} size={14} />
<span>{toast.msg}</span>
</div>
)}
{tweaksMode && (
<TweaksPanel
theme={theme} setTheme={(v) => { setTheme(v); pushEdits({ theme: v }); }}
accentIdx={accentIdx} setAccentIdx={(v) => { setAccentIdx(v); pushEdits({ accentIdx: v }); }}
density={density} setDensity={(v) => { setDensity(v); pushEdits({ density: v }); }}
motion={motion} setMotion={(v) => { setMotion(v); pushEdits({ motion: v }); }}
radius={radius} setRadius={(v) => { setRadius(v); pushEdits({ radius: v }); }}
/>
)}
</>
);
};
const DemoSwitcher = ({ view, setView, onLogin, onSearch, onSettings, onProfile, onEmoji, onTheme, theme }) => {
return (
<div className="demo-switcher">
<button className={view === 'chat' ? 'active' : ''} onClick={() => setView('chat')}>Chat</button>
<button className={view === 'dm' ? 'active' : ''} onClick={() => setView('dm')}>DM</button>
<button className={view === 'voice' ? 'active' : ''} onClick={() => setView('voice')}>Voice</button>
<div style={{width: 1, background: 'var(--border)', margin: '4px 2px'}} />
<button onClick={(e) => onProfile(e)}>Profile</button>
<button onClick={(e) => onEmoji(e)}>Emoji</button>
<button onClick={onSearch}>Search</button>
<button onClick={onSettings}>Settings</button>
<button onClick={onLogin}>Login</button>
<div style={{width: 1, background: 'var(--border)', margin: '4px 2px'}} />
<button onClick={onTheme} title="Toggle theme">
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={12} />
</button>
</div>
);
};
const TweaksPanel = ({ theme, setTheme, accentIdx, setAccentIdx, density, setDensity, motion, setMotion, radius, setRadius }) => (
<div className="tweaks-panel">
<div className="tweaks-header">
<div className="tweaks-title"><Icon name="sparkle" size={14} /> Tweaks</div>
<span style={{fontSize: 10, color: 'var(--fg-dim)', fontFamily: 'var(--font-mono)'}}>pyramid.sys</span>
</div>
<div className="tweaks-body">
<div className="tweak-row">
<div className="tweak-label"><span>Theme</span><span className="val">{theme}</span></div>
<div className="seg">
<button className={theme === 'dark' ? 'active' : ''} onClick={() => setTheme('dark')}>Dark</button>
<button className={theme === 'light' ? 'active' : ''} onClick={() => setTheme('light')}>Light</button>
</div>
</div>
<div className="tweak-row">
<div className="tweak-label"><span>Accent</span><span className="val">{ACCENT_PRESETS[accentIdx].name}</span></div>
<div className="tweak-swatches">
{ACCENT_PRESETS.map((p, i) => (
<div key={p.name} className={`tweak-swatch ${accentIdx === i ? 'active' : ''}`}
style={{ background: `hsl(${p.h} ${p.s}% ${p.l}%)` }}
onClick={() => setAccentIdx(i)} title={p.name} />
))}
</div>
</div>
<div className="tweak-row">
<div className="tweak-label"><span>Density</span><span className="val">{density === 0.85 ? 'compact' : density === 1.2 ? 'cozy' : 'default'}</span></div>
<div className="seg">
<button className={density === 0.85 ? 'active' : ''} onClick={() => setDensity(0.85)}>Compact</button>
<button className={density === 1 ? 'active' : ''} onClick={() => setDensity(1)}>Default</button>
<button className={density === 1.2 ? 'active' : ''} onClick={() => setDensity(1.2)}>Cozy</button>
</div>
</div>
<div className="tweak-row">
<div className="tweak-label"><span>Corner radius</span><span className="val">{radius}px</span></div>
<input type="range" min="4" max="20" value={radius} onChange={e => setRadius(+e.target.value)} />
</div>
<div className="tweak-row">
<div className="tweak-label"><span>Motion</span><span className="val">{Math.round(motion*100)}%</span></div>
<input type="range" min="0" max="100" value={motion*100} onChange={e => setMotion(+e.target.value/100)} />
</div>
</div>
</div>
);
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
+273
View File
@@ -0,0 +1,273 @@
/* Chat view — messages, composer, hover actions */
const renderText = (text, onMention) => {
if (!text) return null;
const parts = [];
const regex = /(@\w+|#[\w-]+|https?:\/\/\S+|`[^`]+`)/g;
let last = 0;
text.replace(regex, (m, _, idx) => {
if (idx > last) parts.push(text.slice(last, idx));
if (m.startsWith('@')) parts.push(<span key={idx} className="mention" onClick={() => onMention?.(m)}>{m}</span>);
else if (m.startsWith('#')) parts.push(<a key={idx}>{m}</a>);
else if (m.startsWith('http')) parts.push(<a key={idx}>{m}</a>);
else if (m.startsWith('`')) parts.push(<code key={idx}>{m.slice(1, -1)}</code>);
last = idx + m.length;
return m;
});
if (last < text.length) parts.push(text.slice(last));
return parts;
};
const Message = ({ msg, onOpenProfile, onReact, onOpenActions, onOpenEmoji }) => {
const hasAvatar = !msg.continuation;
return (
<div className={`msg-group ${hasAvatar ? 'has-avatar' : ''}`} data-msg-id={msg.id}>
<div className="msg-avatar-col">
{hasAvatar ? (
<div className="msg-avatar avatar" style={{ background: msg.color }} onClick={(e) => onOpenProfile?.(msg.author, e)}>
{msg.avatar}
</div>
) : (
<div className="msg-timestamp-col">{msg.time}</div>
)}
</div>
<div className="msg-body">
{hasAvatar && (
<div className="msg-header">
<span className="msg-author" onClick={(e) => onOpenProfile?.(msg.author, e)}>{msg.author}</span>
<span className="msg-time">{msg.time}</span>
</div>
)}
{msg.text && <div className="msg-text">{renderText(msg.text)}</div>}
{msg.image && (
<div className="msg-image" style={{
background: 'repeating-linear-gradient(45deg, var(--bg-3), var(--bg-3) 8px, var(--bg-2) 8px, var(--bg-2) 16px)'
}}>
<span className="msg-image-label">{msg.image}</span>
</div>
)}
{msg.reactions && msg.reactions.length > 0 && (
<div className="reactions">
{msg.reactions.map((r, i) => (
<button key={i} className={`reaction ${r.mine ? 'mine' : ''}`} onClick={() => onReact?.(msg.id, r.emoji)}>
<span className="emoji">{r.emoji}</span>
<span className="count">{r.count}</span>
</button>
))}
<button className="reaction-add" onClick={(e) => onOpenEmoji?.(msg.id, e)}>
<Icon name="smile" size={12} />
</button>
</div>
)}
</div>
<div className="msg-actions">
<button className="msg-action emoji-btn" title="Reaction" onClick={(e) => onOpenEmoji?.(msg.id, e)}>
<Icon name="smile" size={16} />
</button>
<button className="msg-action" title="Reply in thread"><Icon name="reply" size={16} /></button>
<button className="msg-action" title="Pin"><Icon name="pin" size={16} /></button>
<button className="msg-action" title="More" onClick={(e) => onOpenActions?.(msg.id, e)}>
<Icon name="dots" size={16} />
</button>
</div>
</div>
);
};
const ChatHeader = ({ room, roomsData, onToggleMembers, membersOpen, onOpenSearch, onToggleRail, railCollapsed }) => {
const roomDef = findRoom(roomsData, room);
const isDM = roomDef?.type === 'dm';
const title = roomDef?.name || room;
const topic = isDM
? `direct message with ${roomDef.name}`
: 'hi-fi corner for paintings, zines, riso, and whatever else';
return (
<div className="chat-header">
<button className="icon-btn" onClick={onToggleRail} title={railCollapsed ? 'Show spaces' : 'Hide spaces'}>
<Icon name="sidebarLeft" size={16} />
</button>
<div className="chat-header-title">
{isDM ? (
<div className="avatar" style={{ width: 26, height: 26, borderRadius: 8, background: roomDef.color, fontSize: 11, position: 'relative' }}>
{roomDef.avatar}
<span className={`presence-dot ${roomDef.presence}`} style={{ width: 8, height: 8, borderWidth: 2 }} />
</div>
) : (
<span style={{color: 'var(--fg-dim)'}}>{roomIcon(roomDef?.type || 'text')}</span>
)}
<span>{title}</span>
</div>
{!isDM && <div className="chat-header-topic">{topic}</div>}
{isDM && <div style={{flex: 1}} />}
<div className="chat-header-actions">
{isDM && (
<>
<button className="icon-btn call-btn voice" title="Start voice call" onClick={() => window.__startDMCall?.(roomDef, 'voice')}>
<Icon name="voice" size={16} />
</button>
<button className="icon-btn call-btn video" title="Start video call" onClick={() => window.__startDMCall?.(roomDef, 'video')}>
<Icon name="video" size={16} />
</button>
<div style={{width: 1, height: 20, background: 'var(--border)', margin: '0 4px'}} />
</>
)}
<button className="icon-btn" title="Threads"><Icon name="inbox" size={16} /></button>
<button className="icon-btn" title="Pinned"><Icon name="pin" size={16} /></button>
<button className="icon-btn" title="Search" onClick={onOpenSearch}><Icon name="search" size={16} /></button>
<button className={`icon-btn ${membersOpen ? 'active' : ''}`} onClick={onToggleMembers} title="Members"
style={membersOpen ? { background: 'var(--bg-hover)', color: 'var(--fg)'} : {}}>
<Icon name="users" size={16} />
</button>
</div>
</div>
);
};
const findRoom = (roomsData, id) => {
if (!roomsData) return null;
for (const section of roomsData.sections) {
const r = section.rooms.find(r => r.id === id);
if (r) return r;
}
return null;
};
const Composer = ({ room, onSend, onOpenEmoji }) => {
const [text, setText] = React.useState('');
const ref = React.useRef(null);
const submit = () => {
if (text.trim()) {
onSend(text);
setText('');
}
};
const handleKey = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const roomName = room?.name || room;
const placeholder = typeof roomName === 'string' && roomName.startsWith('dm-')
? `Message…`
: `Message #${typeof roomName === 'string' ? roomName.replace('dm-', '') : 'room'}`;
return (
<div className="composer-wrap">
<div className="composer">
<div className="composer-input-row">
<button className="icon-btn composer-plus" title="Attach"><Icon name="plus" size={16} /></button>
<textarea
ref={ref}
className="composer-input"
placeholder={placeholder}
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={handleKey}
rows={1}
/>
<div className="composer-inline-tools">
<button className="icon-btn" title="GIF"><Icon name="gif" size={14} /></button>
<button className="icon-btn" title="Emoji" onClick={(e) => onOpenEmoji('composer', e)}>
<Icon name="smile" size={14} />
</button>
<button className="icon-btn" title="Mention"><Icon name="at" size={13} /></button>
</div>
<button className={`composer-send ${text.trim() ? 'active' : ''}`} onClick={submit} title="Send">
<Icon name="send" size={14} />
</button>
</div>
</div>
</div>
);
};
const MembersPanel = ({ room, members }) => {
const data = members || { online: [], offline: [] };
return (
<aside className="members">
<div className="members-header">
<Icon name="users" size={16} />
<span>Members</span>
<span className="count">{data.online.length + data.offline.length}</span>
</div>
<div className="members-list">
{data.online.length > 0 && (
<>
<div className="member-section-head">Online {data.online.length}</div>
{data.online.map((m, i) => (
<div className="member-item" key={'o'+i}>
<div className="avatar" style={{ background: m.color }}>
{m.avatar}
<span className={`presence-dot ${m.status}`} />
</div>
<div className="member-info">
<div className="member-name">{m.name}{m.self ? ' (you)' : ''}</div>
{m.note && <div className="member-note">{m.note}</div>}
</div>
</div>
))}
</>
)}
{data.offline.length > 0 && (
<>
<div className="member-section-head" style={{marginTop: 14}}>Offline {data.offline.length}</div>
{data.offline.map((m, i) => (
<div className="member-item offline" key={'f'+i}>
<div className="avatar" style={{ background: m.color }}>{m.avatar}</div>
<div className="member-info">
<div className="member-name">{m.name}</div>
</div>
</div>
))}
</>
)}
</div>
</aside>
);
};
const DateDivider = ({ label }) => <div className="date-divider"><span>{label}</span></div>;
const ChatView = ({ room, roomsData, messages, members, onOpenProfile, onOpenActions, onOpenEmoji, onToggleMembers, membersOpen, onOpenSearch, onToggleRail, railCollapsed, onSend, onReact }) => {
const messagesRef = React.useRef(null);
React.useEffect(() => {
if (messagesRef.current) messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
}, [room, messages?.length]);
const roomDef = findRoom(roomsData, room);
return (
<main className="chat">
<ChatHeader
room={room}
roomsData={roomsData}
onToggleMembers={onToggleMembers}
membersOpen={membersOpen}
onOpenSearch={onOpenSearch}
onToggleRail={onToggleRail}
railCollapsed={railCollapsed}
/>
<div className="messages" ref={messagesRef}>
<DateDivider label="Today · April 19" />
{messages?.map(m => (
<Message
key={m.id}
msg={m}
onOpenProfile={onOpenProfile}
onReact={onReact}
onOpenActions={onOpenActions}
onOpenEmoji={onOpenEmoji}
/>
))}
</div>
<Composer room={roomDef || room} onSend={onSend} onOpenEmoji={onOpenEmoji} />
</main>
);
};
Object.assign(window, { ChatView, Message, Composer, ChatHeader, MembersPanel, DateDivider, findRoom, roomIcon });
+716
View File
@@ -0,0 +1,716 @@
/* Modals, popovers, voice channel, login */
const EmojiPicker = ({ anchor, onPick, onClose }) => {
const [tab, setTab] = React.useState('recent');
const [q, setQ] = React.useState('');
const [hover, setHover] = React.useState(null);
const ref = React.useRef(null);
const cats = window.PYRAMID_DATA.emojiCategories;
React.useEffect(() => {
const h = (e) => {
if (ref.current && !ref.current.contains(e.target)) onClose();
};
setTimeout(() => document.addEventListener('mousedown', h), 50);
return () => document.removeEventListener('mousedown', h);
}, []);
const style = anchor ? { bottom: window.innerHeight - anchor.top + 8, right: Math.max(20, window.innerWidth - anchor.right) } : { bottom: 80, right: 20 };
const filtered = q ? cats.flatMap(c => c.emojis.filter(e => c.key.includes(q.toLowerCase()))) : null;
return (
<div className="emoji-picker" ref={ref} style={style}>
<div className="emoji-picker-search">
<input placeholder="Search emoji…" autoFocus value={q} onChange={e => setQ(e.target.value)} />
</div>
<div className="emoji-picker-tabs">
{cats.map(c => (
<div key={c.key} className={`emoji-tab ${tab === c.key ? 'active' : ''}`} onClick={() => setTab(c.key)} title={c.title}>
{c.icon}
</div>
))}
</div>
<div className="emoji-grid-wrap">
{filtered ? (
<>
<div className="emoji-category-title">Results</div>
<div className="emoji-grid">
{filtered.map((e, i) => <div key={i} className="emoji-cell" onMouseEnter={() => setHover({ emoji: e, name: 'emoji' })} onClick={() => { onPick(e); onClose(); }}>{e}</div>)}
</div>
</>
) : cats.map(c => (
<div key={c.key} id={`cat-${c.key}`}>
<div className="emoji-category-title">{c.title}</div>
<div className="emoji-grid">
{c.emojis.map((e, i) => (
<div key={i} className="emoji-cell"
onMouseEnter={() => setHover({ emoji: e, name: c.title.toLowerCase() })}
onClick={() => { onPick(e); onClose(); }}>
{e}
</div>
))}
</div>
</div>
))}
</div>
<div className="emoji-picker-preview">
{hover ? (
<>
<span className="big">{hover.emoji}</span>
<div>
<div className="name">{hover.name}</div>
<div className="shortcode">:{hover.name.replace(/\s/g, '_')}:</div>
</div>
</>
) : (
<div className="shortcode" style={{fontSize: 12}}>pick an emoji · 😊</div>
)}
</div>
</div>
);
};
const MessageActionsMenu = ({ anchor, onClose, onAction }) => {
const ref = React.useRef(null);
React.useEffect(() => {
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
setTimeout(() => document.addEventListener('mousedown', h), 50);
return () => document.removeEventListener('mousedown', h);
}, []);
const style = anchor ? { top: anchor.top, left: Math.min(anchor.right, window.innerWidth - 230) } : {};
return (
<div className="context-menu" ref={ref} style={style}>
<div className="context-item" onClick={() => { onAction('reply'); onClose(); }}><Icon name="reply" size={14} /> Reply <span className="shortcut">R</span></div>
<div className="context-item" onClick={() => { onAction('thread'); onClose(); }}><Icon name="inbox" size={14} /> Reply in thread</div>
<div className="context-item" onClick={() => { onAction('edit'); onClose(); }}><Icon name="edit" size={14} /> Edit <span className="shortcut">E</span></div>
<div className="context-divider" />
<div className="context-item" onClick={() => { onAction('pin'); onClose(); }}><Icon name="pin" size={14} /> Pin to room</div>
<div className="context-item" onClick={() => { onAction('bookmark'); onClose(); }}><Icon name="bookmark" size={14} /> Save for later</div>
<div className="context-item" onClick={() => { onAction('copy'); onClose(); }}><Icon name="copy" size={14} /> Copy text <span className="shortcut">C</span></div>
<div className="context-item" onClick={() => { onAction('link'); onClose(); }}><Icon name="link" size={14} /> Copy message link</div>
<div className="context-divider" />
<div className="context-item danger" onClick={() => { onAction('delete'); onClose(); }}><Icon name="trash" size={14} /> Delete message <span className="shortcut"></span></div>
</div>
);
};
const ProfilePopover = ({ anchor, profile, onClose }) => {
const ref = React.useRef(null);
React.useEffect(() => {
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
setTimeout(() => document.addEventListener('mousedown', h), 50);
return () => document.removeEventListener('mousedown', h);
}, []);
const style = anchor
? { top: Math.min(anchor.top, window.innerHeight - 440), left: Math.min(anchor.right + 10, window.innerWidth - 340) }
: { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
const p = profile || { name: 'juno', handle: '@juno:pyramid.chat', avatar: 'J', color: '#06b6d4', status: 'online', about: 'risograph + zines. teaching at the atelier tuesdays.', roles: [{ name: 'founder', color: '#f59e0b' }, { name: 'print-nerd', color: '#06b6d4' }] };
return (
<div className="profile-popover" ref={ref} style={style}>
<div className="profile-banner" />
<div className="profile-content">
<div className="profile-avatar" style={{ background: `linear-gradient(135deg, ${p.color}, ${p.color}cc)` }}>
{p.avatar}
<span className={`presence-dot ${p.status}`} style={{ width: 16, height: 16, borderWidth: 3 }} />
</div>
<div className="profile-name">{p.name}</div>
<div className="profile-handle">{p.handle}</div>
<div className="profile-actions">
<button className="profile-btn primary"><Icon name="send" size={13} /> Message</button>
<button className="profile-btn"><Icon name="voice" size={13} /> Call</button>
<button className="profile-btn" style={{ flex: 'none', padding: '7px 10px' }}><Icon name="dotsV" size={14} /></button>
</div>
<div className="profile-section">
<div className="profile-section-title">About</div>
<div className="profile-about">{p.about}</div>
</div>
<div className="profile-section">
<div className="profile-section-title">Roles</div>
<div className="profile-roles">
{(p.roles || []).map((r, i) => (
<span key={i} className="profile-role"><span className="dot" style={{ background: r.color }} /> {r.name}</span>
))}
</div>
</div>
<div className="profile-section">
<div className="profile-section-title">Member since</div>
<div className="profile-about">March 2024 · via pyramid.chat</div>
</div>
</div>
</div>
);
};
const SettingsModal = ({ onClose, theme, onTheme, accentPresets, accent, onAccent, density, onDensity, motion, onMotion, radius, onRadius }) => {
const [section, setSection] = React.useState('appearance');
return (
<div className="overlay" onClick={onClose}>
<div className="modal settings-modal" onClick={e => e.stopPropagation()}>
<nav className="settings-nav">
<div className="settings-nav-section">Account</div>
<div className={`settings-nav-item ${section === 'profile' ? 'active' : ''}`} onClick={() => setSection('profile')}><Icon name="users" size={14}/> My profile</div>
<div className={`settings-nav-item ${section === 'notifications' ? 'active' : ''}`} onClick={() => setSection('notifications')}><Icon name="bell" size={14}/> Notifications</div>
<div className="settings-nav-section" style={{marginTop: 14}}>App</div>
<div className={`settings-nav-item ${section === 'appearance' ? 'active' : ''}`} onClick={() => setSection('appearance')}><Icon name="palette" size={14}/> Appearance</div>
<div className={`settings-nav-item ${section === 'voice' ? 'active' : ''}`} onClick={() => setSection('voice')}><Icon name="voice" size={14}/> Voice &amp; video</div>
<div className={`settings-nav-item ${section === 'keys' ? 'active' : ''}`} onClick={() => setSection('keys')}><Icon name="settings" size={14}/> Keybinds</div>
<div className="settings-nav-section" style={{marginTop: 14}}>Privacy</div>
<div className={`settings-nav-item ${section === 'privacy' ? 'active' : ''}`} onClick={() => setSection('privacy')}><Icon name="lock" size={14}/> Privacy &amp; safety</div>
<div className={`settings-nav-item ${section === 'sessions' ? 'active' : ''}`} onClick={() => setSection('sessions')}><Icon name="globe" size={14}/> Sessions</div>
</nav>
<div className="settings-content">
<button className="modal-close" onClick={onClose}><Icon name="close" size={14} /></button>
{section === 'appearance' && (
<>
<h2>Appearance</h2>
<div className="settings-subtitle">Make Pyramid feel the way you want. Changes apply immediately.</div>
<div className="settings-group">
<div className="settings-group-title">Theme</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Mode</div>
<div className="settings-row-desc">Switch between dark and light</div>
</div>
<div className="seg" style={{ width: 200 }}>
<button className={theme === 'dark' ? 'active' : ''} onClick={() => onTheme('dark')}>Dark</button>
<button className={theme === 'light' ? 'active' : ''} onClick={() => onTheme('light')}>Light</button>
</div>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Accent color</div>
<div className="settings-row-desc">Applies to highlights, buttons, active states</div>
</div>
<div className="tweak-swatches">
{accentPresets.map(p => (
<div key={p.name} className={`tweak-swatch ${accent === p.h ? 'active' : ''}`}
style={{ background: `hsl(${p.h} ${p.s}% ${p.l}%)` }}
title={p.name}
onClick={() => onAccent(p)} />
))}
</div>
</div>
</div>
<div className="settings-group">
<div className="settings-group-title">Density &amp; shape</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Density</div>
<div className="settings-row-desc">Cozy = more breathing room · compact = more on screen</div>
</div>
<div className="seg" style={{ width: 220 }}>
<button className={density === 0.85 ? 'active' : ''} onClick={() => onDensity(0.85)}>Compact</button>
<button className={density === 1 ? 'active' : ''} onClick={() => onDensity(1)}>Default</button>
<button className={density === 1.2 ? 'active' : ''} onClick={() => onDensity(1.2)}>Cozy</button>
</div>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Corner roundness</div>
<div className="settings-row-desc">{radius}px</div>
</div>
<input type="range" min="4" max="20" value={radius} onChange={e => onRadius(+e.target.value)} style={{ width: 180, accentColor: 'var(--accent)' }} />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Motion intensity</div>
<div className="settings-row-desc">How bouncy transitions feel ({Math.round(motion*100)}%)</div>
</div>
<input type="range" min="0" max="100" value={motion*100} onChange={e => onMotion(+e.target.value/100)} style={{ width: 180, accentColor: 'var(--accent)' }} />
</div>
</div>
<div className="settings-group">
<div className="settings-group-title">Message display</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Show avatars on every message</div>
<div className="settings-row-desc">Off groups consecutive messages from the same person</div>
</div>
<div className="toggle" />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Animated emoji &amp; stickers</div>
<div className="settings-row-desc">Play animations on hover</div>
</div>
<div className="toggle on" />
</div>
</div>
</>
)}
{section === 'profile' && (
<>
<h2>My profile</h2>
<div className="settings-subtitle">How you appear across pyramid.</div>
<div className="account-card">
<div className="avatar">M<span className="presence-dot online" style={{width:16,height:16,borderWidth:3}}/></div>
<div>
<div className="account-name">mira</div>
<div className="account-handle">@mira:pyramid.chat</div>
<div className="profile-about" style={{marginTop: 4}}>in the zone 🎨</div>
</div>
<button className="profile-btn" style={{ flex: 'none', marginLeft: 'auto' }}><Icon name="edit" size={13}/> Edit</button>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Display name</div>
<div className="settings-row-desc">mira</div>
</div>
<button className="profile-btn" style={{ flex: 'none' }}>Change</button>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Avatar</div>
<div className="settings-row-desc">Upload an image or pick a gradient</div>
</div>
<button className="profile-btn" style={{ flex: 'none' }}>Upload</button>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Status message</div>
<div className="settings-row-desc">Shown next to your name to friends</div>
</div>
<button className="profile-btn" style={{ flex: 'none' }}>Edit</button>
</div>
</>
)}
{section === 'notifications' && (
<>
<h2>Notifications</h2>
<div className="settings-subtitle">Tune what pings you and when.</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Enable desktop notifications</div>
<div className="settings-row-desc">Lets pyramid show OS-level alerts</div>
</div>
<div className="toggle on" />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Mentions only</div>
<div className="settings-row-desc">Suppress everything except @-mentions and DMs</div>
</div>
<div className="toggle" />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Notification sound</div>
<div className="settings-row-desc">A soft little chime</div>
</div>
<select className="settings-select">
<option>Gentle</option><option>Classic</option><option>Silent</option>
</select>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Do not disturb</div>
<div className="settings-row-desc">Mute notifications on a schedule</div>
</div>
<div className="toggle" />
</div>
</>
)}
{section === 'voice' && (
<>
<h2>Voice &amp; video</h2>
<div className="settings-subtitle">Mic, camera, and call preferences.</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Input device</div>
<div className="settings-row-desc">Your mic</div>
</div>
<select className="settings-select"><option>Built-in microphone</option><option>AirPods Pro</option></select>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Noise suppression</div>
<div className="settings-row-desc">Smart mode based on what you're saying</div>
</div>
<div className="toggle on" />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Echo cancellation</div>
<div className="settings-row-desc">Strips feedback from speaker bleed</div>
</div>
<div className="toggle on" />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Push to talk</div>
<div className="settings-row-desc">Hold a key to transmit</div>
</div>
<div className="toggle" />
</div>
</>
)}
{section === 'privacy' && (
<>
<h2>Privacy &amp; safety</h2>
<div className="settings-subtitle">Pyramid is end-to-end encrypted by default.</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Who can DM me</div>
<div className="settings-row-desc">Control who starts a direct message with you</div>
</div>
<select className="settings-select"><option>Everyone</option><option>Friends &amp; shared spaces</option><option>Only friends</option></select>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Read receipts</div>
<div className="settings-row-desc">Let others see when you've read their messages</div>
</div>
<div className="toggle on" />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Sent typing indicators</div>
<div className="settings-row-desc">Others see when you're composing</div>
</div>
<div className="toggle on" />
</div>
</>
)}
{section === 'keys' && (
<>
<h2>Keybinds</h2>
<div className="settings-subtitle">Quick movements.</div>
{[
['Quick switcher', 'K'],
['Mark room as read', ''],
['Jump to unread', 'J'],
['Toggle left rail', 'B'],
['Toggle members', 'U'],
['Emoji picker', 'E'],
].map(([label, key]) => (
<div className="settings-row" key={label}>
<div className="settings-row-label">
<div className="settings-row-title">{label}</div>
</div>
<kbd style={{padding: '3px 8px', background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 6, fontFamily: 'var(--font-mono)', fontSize: 12}}>{key}</kbd>
</div>
))}
</>
)}
{section === 'sessions' && (
<>
<h2>Sessions</h2>
<div className="settings-subtitle">Devices where you're currently signed in.</div>
{[
{ dev: 'MacBook Pro · Safari', loc: 'Berlin, DE · now', current: true },
{ dev: 'iPhone 15 · Pyramid iOS', loc: 'Berlin, DE · 2h ago' },
{ dev: 'Chrome · ThinkPad', loc: 'Lisbon, PT · 3d ago' },
].map((s, i) => (
<div className="settings-row" key={i}>
<div className="settings-row-label">
<div className="settings-row-title">{s.dev}{s.current && <span className="badge" style={{marginLeft:8, background:'var(--accent-soft)', color:'var(--accent)'}}>this device</span>}</div>
<div className="settings-row-desc">{s.loc}</div>
</div>
{!s.current && <button className="profile-btn" style={{flex:'none', color:'var(--danger)'}}>Revoke</button>}
</div>
))}
</>
)}
</div>
</div>
</div>
);
};
/* ===== Search ===== */
const SearchModal = ({ onClose, onJump }) => {
const [q, setQ] = React.useState('');
const [filter, setFilter] = React.useState('all');
const allResults = [
{ kind: 'message', room: '#general', author: 'juno', avatar: 'J', color: '#06b6d4', snippet: 'turned out way better than expected — the mis-registration is doing a LOT of heavy lifting', time: 'today · 2:14 PM' },
{ kind: 'message', room: '#resources', author: 'juno', avatar: 'J', color: '#06b6d4', snippet: 'risograph studio on baker street — 4hr blocks for $30', time: 'yesterday' },
{ kind: 'room', name: 'critique-circle', snippet: 'Atelier · 142 members', avatar: '#', color: '#f59e0b' },
{ kind: 'person', name: 'juno', snippet: '@juno:pyramid.chat · online', avatar: 'J', color: '#06b6d4' },
{ kind: 'message', room: 'juno', author: 'juno', avatar: 'J', color: '#06b6d4', snippet: 'bringing pastries — riso prints later', time: '3d ago' },
{ kind: 'file', name: 'zine-layout-final.pdf', snippet: 'Shared by juno · Atelier #wip · 2MB', avatar: '📄' },
];
const filtered = allResults.filter(r => filter === 'all' || r.kind === filter);
return (
<div className="overlay" onClick={onClose}>
<div className="modal search-modal" onClick={e => e.stopPropagation()}>
<div className="search-input-row">
<Icon name="search" size={18} />
<input autoFocus placeholder="Search messages, rooms, people, files…" value={q} onChange={e => setQ(e.target.value)} />
<span className="esc">esc</span>
</div>
<div className="search-filters">
{[
{ k: 'all', label: 'All', icon: 'sparkle' },
{ k: 'message', label: 'Messages', icon: 'hash' },
{ k: 'person', label: 'People', icon: 'users' },
{ k: 'room', label: 'Rooms', icon: 'hash' },
{ k: 'file', label: 'Files', icon: 'paperclip' },
].map(f => (
<button key={f.k} className={`search-chip ${filter === f.k ? 'active' : ''}`} onClick={() => setFilter(f.k)}>
<Icon name={f.icon} size={11} /> {f.label}
</button>
))}
<button className="search-chip"><Icon name="filter" size={11} /> From: anyone</button>
<button className="search-chip"><Icon name="filter" size={11} /> In: any room</button>
<button className="search-chip"><Icon name="filter" size={11} /> Has: all</button>
</div>
<div className="search-results">
{filtered.map((r, i) => (
<div key={i} className={`search-result ${i === 0 ? 'selected' : ''}`} onClick={onClose}>
<div className="avatar" style={{background: r.color || 'var(--bg-3)'}}>{r.avatar}</div>
<div className="search-result-content">
<div className="search-result-top">
<span className="search-result-name">{r.name || r.author}</span>
<span className="search-result-meta">{r.room || r.kind}{r.time ? ` · ${r.time}` : ''}</span>
</div>
<div className="search-result-snippet" dangerouslySetInnerHTML={{
__html: q
? r.snippet.replace(new RegExp(`(${q})`, 'ig'), '<mark>$1</mark>')
: r.snippet
}} />
</div>
</div>
))}
{filtered.length === 0 && (
<div style={{padding: 40, textAlign: 'center', color: 'var(--fg-dim)'}}>nothing here try a different filter?</div>
)}
</div>
<div className="search-footer">
<span><kbd></kbd><kbd></kbd> navigate</span>
<span><kbd></kbd> select</span>
<span><kbd></kbd><kbd></kbd> open in new pane</span>
<span style={{marginLeft:'auto'}}>{filtered.length} results</span>
</div>
</div>
</div>
);
};
/* ===== Voice Channel ===== */
const VoiceChannel = ({ call, onLeave, onToggleRail, onOpenProfile, onToggleMic, onToggleDeaf, onToggleCam, onToggleScreen }) => {
const [fullscreen, setFullscreen] = React.useState(false);
const [qualityOpen, setQualityOpen] = React.useState(false);
const [quality, setQuality] = React.useState('1080p60');
React.useEffect(() => {
if (!qualityOpen) return;
const h = (e) => { if (!e.target.closest('.stream-quality-menu') && !e.target.closest('[data-quality-trigger]')) setQualityOpen(false); };
setTimeout(() => document.addEventListener('mousedown', h), 50);
return () => document.removeEventListener('mousedown', h);
}, [qualityOpen]);
const participants = call?.participants || [];
const streaming = call?.streaming;
const streamer = call?.streamer || 'mira';
if (!call?.active) {
return (
<main className="chat">
<div className="chat-header">
<button className="icon-btn" onClick={onToggleRail}><Icon name="sidebarLeft" size={16} /></button>
<div className="chat-header-title"><Icon name="voice" size={16} /><span>No active call</span></div>
</div>
<div className="voice-hero" style={{alignItems: 'center', justifyContent: 'center'}}>
<div style={{color: 'var(--fg-muted)', fontSize: 14, textAlign: 'center'}}>
<div style={{fontSize: 32, marginBottom: 12, opacity: 0.4}}>𐃏</div>
<div>Join a voice room to start a call</div>
</div>
</div>
</main>
);
}
return (
<main className="chat">
<div className="chat-header">
<button className="icon-btn" onClick={onToggleRail}><Icon name="sidebarLeft" size={16} /></button>
<div className="chat-header-title">
<Icon name="voice" size={16} />
<span>{call?.roomName || 'The Lounge'}</span>
<span className="voice-live-pill" style={{marginLeft: 8}}>
<span className="live-dot" /> Live · {participants.length}
</span>
</div>
<div className="chat-header-actions">
<button className="icon-btn"><Icon name="users" size={16} /></button>
<button className="icon-btn"><Icon name="settings" size={16} /></button>
</div>
</div>
<div className="voice-hero">
<div className="voice-layout">
{streaming ? (
<>
<div className={`stream-hero ${fullscreen ? 'fullscreen' : ''}`}>
<div className="stream-hero-content">
<div className="stream-hero-center">
<div className="big-icon"><Icon name="screen" size={28} /></div>
<div className="big-label">{streamer}'s screen · {quality.toUpperCase()}</div>
</div>
</div>
<div className="stream-hero-topbar">
<span className="stream-live-pill"><span className="dot" /> Live</span>
<div className="stream-hero-title">{streamer}'s stream</div>
<div style={{flex: 1}} />
<div className="stream-hero-viewers"><Icon name="users" size={11} /> {participants.length} watching</div>
<div className="stream-hero-viewers"><Icon name="sparkle" size={11} /> 2.3 mbps</div>
</div>
<div className="stream-hero-bottombar">
<button className="stream-hero-btn primary" onClick={onToggleScreen} title="Stop stream">
<Icon name="screen" size={16} />
</button>
<button className="stream-hero-btn" data-quality-trigger onClick={() => setQualityOpen(v => !v)} title="Quality">
<Icon name="settings" size={16} />
</button>
<div className="stream-hero-spacer" />
<button className="stream-hero-btn" title="Picture-in-picture">
<Icon name="sidebarLeft" size={16} />
</button>
<button className="stream-hero-btn" onClick={() => setFullscreen(f => !f)} title={fullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
<Icon name={fullscreen ? 'close' : 'globe'} size={16} />
</button>
</div>
{qualityOpen && (
<div className="stream-quality-menu">
<div className="stream-quality-title">Video quality</div>
{[
{ k: 'source', label: 'Source', hint: 'as-is' },
{ k: '1080p60', label: '1080p', hint: '60 fps' },
{ k: '1080p30', label: '1080p', hint: '30 fps' },
{ k: '720p60', label: '720p', hint: '60 fps' },
{ k: '480p', label: '480p', hint: 'data-saver' },
].map(q => (
<div key={q.k} className="stream-quality-item" onClick={() => { setQuality(q.k); setQualityOpen(false); }}>
<span className="check-col">{quality === q.k && <Icon name="check" size={12} />}</span>
<span className="label">{q.label}</span>
<span className="hint">{q.hint}</span>
</div>
))}
<div className="stream-quality-divider" />
<div className="stream-quality-title">Audio</div>
<div className="stream-quality-item"><span className="check-col"><Icon name="check" size={12} /></span><span className="label">Stereo · 256 kbps</span></div>
<div className="stream-quality-item"><span className="check-col"></span><span className="label">Mono · 96 kbps</span></div>
</div>
)}
</div>
<div className="voice-participants-row">
{participants.map((p, i) => (
<div key={i} className={`voice-tile small ${p.speaking ? 'speaking' : ''}`}>
<div className="voice-tile-avatar" style={{ background: `linear-gradient(135deg, ${p.color}, ${p.color}99)` }} onClick={() => onOpenProfile?.(p.name)}>{p.avatar}</div>
<div className="voice-tile-name">
{p.speaking && <span className="voice-wave"><span /><span /><span /><span /></span>}
{p.muted && <Icon name="micOff" size={10} />}
<span>{p.name}</span>
</div>
<div className="voice-status-icons">
{p.muted && <div className="voice-status-icon muted"><Icon name="micOff" size={10} /></div>}
</div>
</div>
))}
</div>
</>
) : (
<div className="voice-grid">
{participants.map((p, i) => (
<div key={i} className={`voice-tile ${p.speaking ? 'speaking' : ''}`}>
<div className="voice-tile-avatar" style={{ background: `linear-gradient(135deg, ${p.color}, ${p.color}99)` }} onClick={() => onOpenProfile?.(p.name)}>{p.avatar}</div>
<div className="voice-tile-name">
{p.speaking && <span className="voice-wave"><span /><span /><span /><span /></span>}
{p.muted && <Icon name="micOff" size={11} />}
<span>{p.name}</span>
</div>
<div className="voice-status-icons">
{p.muted && <div className="voice-status-icon muted"><Icon name="micOff" size={12} /></div>}
{!p.cam && <div className="voice-status-icon"><Icon name="videoOff" size={12} /></div>}
</div>
</div>
))}
</div>
)}
</div>
<div className="voice-controls">
<button className={`voice-ctrl-btn ${call?.mic ? '' : 'danger'}`} onClick={onToggleMic}>
<Icon name={call?.mic ? 'mic' : 'micOff'} size={20} />
</button>
<button className={`voice-ctrl-btn ${call?.cam ? 'on' : ''}`} onClick={onToggleCam}>
<Icon name={call?.cam ? 'video' : 'videoOff'} size={20} />
</button>
<button className={`voice-ctrl-btn ${streaming ? 'on' : ''}`} onClick={onToggleScreen} title={streaming ? 'Stop stream' : 'Share screen'}>
<Icon name="screen" size={20} />
</button>
<button className={`voice-ctrl-btn ${call?.deaf ? 'danger' : ''}`} onClick={onToggleDeaf}>
<Icon name="headphones" size={20} />
</button>
<div style={{ width: 1, height: 32, background: 'var(--border)' }} />
<button className="voice-ctrl-btn danger" onClick={onLeave}>
<Icon name="phoneOff" size={20} />
</button>
</div>
</div>
</main>
);
};
/* ===== Login ===== */
const LoginScreen = ({ onLogin }) => {
const [handle, setHandle] = React.useState('mira');
const [pw, setPw] = React.useState('••••••••••');
const [server, setServer] = React.useState('pyramid.chat');
const pyramids = Array.from({ length: 6 }, (_, i) => i);
return (
<div className="login-shell">
{pyramids.map(i => {
const s = 40 + Math.random() * 80;
return (
<div key={i} className="login-float" style={{
left: `${10 + i * 15}%`,
top: `${10 + (i % 3) * 30}%`,
'--rot': `${(i * 23) % 360}deg`,
animationDelay: `${i * 0.7}s`,
}}>
<PyramidMark size={s} />
</div>
);
})}
<form className="login-card" onSubmit={e => { e.preventDefault(); onLogin(); }}>
<div className="login-brand">
<div className="login-brand-mark"><PyramidMark size={56} /></div>
<div>
<div className="login-brand-name" style={{textAlign:'center'}}>pyramid</div>
<div className="login-brand-tag">the chat that stacks. encrypted, decentralized, yours.</div>
</div>
</div>
<div className="login-field">
<div className="login-field-label">Handle</div>
<input value={handle} onChange={e => setHandle(e.target.value)} placeholder="you" />
</div>
<div className="login-field">
<div className="login-field-label">Password</div>
<input type="password" value={pw} onChange={e => setPw(e.target.value)} />
</div>
<div className="login-field">
<div className="login-field-label">Homeserver</div>
<input value={server} onChange={e => setServer(e.target.value)} />
</div>
<button type="submit" className="login-submit">
Enter pyramid <Icon name="arrowUp" size={14} style={{transform:'rotate(90deg)'}} />
</button>
<div className="login-divider">or</div>
<div className="login-alt-row">
<button type="button" className="login-alt-btn"><Icon name="link" size={13} /> Magic link</button>
<button type="button" className="login-alt-btn"><Icon name="lock" size={13} /> Security key</button>
</div>
<div className="login-foot">
new here? <a onClick={onLogin}>make an account </a>
</div>
</form>
</div>
);
};
Object.assign(window, { EmojiPicker, MessageActionsMenu, ProfilePopover, SettingsModal, SearchModal, VoiceChannel, LoginScreen });
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pyramid · Matrix Client</title>
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="components.css" />
<link rel="stylesheet" href="modals.css" />
<link rel="stylesheet" href="callsystem.css" />
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="data.js"></script>
<script type="text/babel" src="icons.jsx"></script>
<script type="text/babel" src="Sidebar.jsx"></script>
<script type="text/babel" src="Chat.jsx"></script>
<script type="text/babel" src="Modals.jsx"></script>
<script type="text/babel" src="App.jsx"></script>
</body>
</html>
+295
View File
@@ -0,0 +1,295 @@
/* Sidebar: Spaces Rail + Rooms panel */
const SpacesRail = ({ spaces, activeSpace, onSelect, collapsed }) => {
if (collapsed) return null;
return (
<aside className="rail">
<div className="rail-home" onClick={() => onSelect('home')}>
<PyramidMark size={30} accent="#fff" />
<span className="rail-tooltip">Pyramid · Home</span>
</div>
<div className="rail-divider" />
{spaces.filter(s => s.kind !== 'home').map(space => (
<div
key={space.id}
className={`rail-item ${activeSpace === space.id ? 'active' : ''} ${space.unread ? 'unread' : ''}`}
onClick={() => onSelect(space.id)}
>
<PyramidGlyph size={30} color={space.color} short={space.short} />
{space.unread > 0 && (
<span className="rail-badge" style={space.mention ? {} : { background: 'var(--accent)', color: 'var(--accent-fg)' }}>
{space.unread > 99 ? '99+' : space.unread}
</span>
)}
<span className="rail-tooltip">{space.name}</span>
</div>
))}
<RailAddBtn />
<div style={{flex: 1}} />
</aside>
);
};
const RailAddBtn = () => {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
setTimeout(() => document.addEventListener('mousedown', h), 50);
return () => document.removeEventListener('mousedown', h);
}, [open]);
return (
<div style={{position: 'relative'}} ref={ref}>
<div className="rail-add" onClick={() => setOpen(v => !v)}>
<Icon name="plus" size={20} />
<span className="rail-tooltip">Create · Join · Discover</span>
</div>
{open && (
<div className="context-menu space-menu-wide" style={{ left: 60, top: 0 }}>
<div className="space-menu-section">Create</div>
<div className="context-item" onClick={() => setOpen(false)}><Icon name="plus" size={14} /> New space <span className="shortcut">N</span></div>
<div className="context-item" onClick={() => setOpen(false)}><Icon name="users" size={14} /> Group DM</div>
<div className="context-divider" />
<div className="space-menu-section">Join</div>
<div className="context-item" onClick={() => setOpen(false)}><Icon name="link" size={14} /> Paste invite link</div>
<div className="context-item" onClick={() => setOpen(false)}><Icon name="at" size={14} /> Join by space ID</div>
<div className="context-divider" />
<div className="space-menu-section">Discover</div>
<div className="context-item" onClick={() => setOpen(false)}><Icon name="globe" size={14} /> Browse public rooms</div>
<div className="context-item" onClick={() => setOpen(false)}><Icon name="sparkle" size={14} /> Featured communities</div>
</div>
)}
</div>
);
};
const roomIcon = (type) => {
if (type === 'voice') return <Icon name="voice" size={15} />;
if (type === 'announce') return <Icon name="announce" size={15} />;
if (type === 'lock') return <Icon name="lock" size={15} />;
return <Icon name="hash" size={15} />;
};
const RoomsPanel = ({ space, roomsData, activeRoom, onSelectRoom, user, onOpenProfile, onOpenSettings, call, onLeaveCall, onToggleStreamCollapse, onToggleFullscreen, onToggleMic, onToggleDeaf, onToggleScreen, onToggleCam, onJumpToCall }) => {
const [collapsed, setCollapsed] = React.useState({});
const [q, setQ] = React.useState('');
const [menuOpen, setMenuOpen] = React.useState(false);
const toggle = (id) => setCollapsed(c => ({ ...c, [id]: !c[id] }));
const matchesQ = (name) => !q || name.toLowerCase().includes(q.toLowerCase());
return (
<aside className="rooms">
<div className="rooms-header">
<div className="rooms-header-icon" />
<div className="rooms-header-title">{roomsData?.name || space}</div>
<button className="icon-btn" title="Notifications"><Icon name="bell" size={15} /></button>
<button className="icon-btn" title="Space menu" onClick={() => setMenuOpen(v => !v)}>
<Icon name="chevronDown" size={15} />
</button>
{menuOpen && <SpaceMenu onClose={() => setMenuOpen(false)} />}
</div>
<div className="rooms-search">
<Icon name="search" size={14} style={{position:'absolute',left:21,top:'50%',transform:'translateY(-50%)',color:'var(--fg-dim)',pointerEvents:'none'}} />
<input placeholder="Filter rooms" value={q} onChange={e => setQ(e.target.value)} />
</div>
<div className="rooms-list">
{roomsData?.sections.map(section => {
const visible = section.rooms.filter(r => matchesQ(r.name));
if (visible.length === 0 && q) return null;
const isCollapsed = collapsed[section.id];
return (
<div className="rooms-section" key={section.id}>
<div className={`rooms-section-head ${isCollapsed ? 'collapsed' : ''}`} onClick={() => toggle(section.id)}>
<Icon name="chevronDown" size={10} className="chevron" />
<span>{section.title}</span>
<span className="count">{section.rooms.length}</span>
<span className="add-btn" onClick={e => { e.stopPropagation(); }}><Icon name="plus" size={12} /></span>
</div>
{!isCollapsed && visible.map(room => (
<RoomItem key={room.id} room={room} active={activeRoom === room.id} onClick={() => onSelectRoom(room.id)} />
))}
</div>
);
})}
</div>
{call?.active && (
<MiniCall
call={call}
onLeave={onLeaveCall}
onToggleStreamCollapse={onToggleStreamCollapse}
onToggleFullscreen={onToggleFullscreen}
onToggleMic={onToggleMic}
onToggleDeaf={onToggleDeaf}
onToggleScreen={onToggleScreen}
onToggleCam={onToggleCam}
onJumpToCall={onJumpToCall}
/>
)}
<UserPanel user={user} onOpenProfile={onOpenProfile} onOpenSettings={onOpenSettings} />
</aside>
);
};
const MiniCall = ({ call, onLeave, onToggleStreamCollapse, onToggleFullscreen, onToggleMic, onToggleDeaf, onToggleScreen, onToggleCam, onJumpToCall }) => {
const [elapsed, setElapsed] = React.useState(0);
React.useEffect(() => {
const i = setInterval(() => setElapsed(e => e + 1), 1000);
return () => clearInterval(i);
}, []);
const fmt = (s) => {
const m = Math.floor(s/60), sec = s%60;
return `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
};
return (
<div className="mini-call">
{call.streaming && (
<div className={`stream-mini ${call.streamCollapsed ? 'collapsed' : ''}`} style={{height: call.streamCollapsed ? 28 : 120}}>
<div className="stream-mini-bar">
<div className="streamer">
<span className="dot" />
<span className="name">{call.streamer}'s stream</span>
</div>
{!call.streamCollapsed && (
<>
<button className="stream-mini-btn" onClick={onToggleFullscreen} title="Fullscreen">
<Icon name="screen" size={12} />
</button>
</>
)}
<button className="chevron-btn" onClick={onToggleStreamCollapse} title={call.streamCollapsed ? 'Expand preview' : 'Collapse preview'}>
<Icon name="chevronDown" size={13} />
</button>
</div>
{!call.streamCollapsed && (
<div className="stream-mini-content" onClick={onJumpToCall}>
<div className="stream-mini-label">LIVE · 1080p · 60fps</div>
</div>
)}
</div>
)}
<div className="mini-call-connected" onClick={onJumpToCall} style={{cursor: 'pointer'}}>
<div className="mini-call-left">
<div className="mini-call-title"><span className="live-dot" /> Voice connected</div>
<div className="mini-call-room">
<Icon name="voice" size={11} /> {call.roomName}
</div>
<div className="mini-call-meta">{fmt(elapsed)} · {call.participants.length} here</div>
</div>
<div className="mini-call-actions" onClick={e => e.stopPropagation()}>
<button className="icon-btn" onClick={onJumpToCall} title="Open call">
<Icon name="arrowUp" size={14} />
</button>
<button className="icon-btn leave" onClick={onLeave} title="Leave call">
<Icon name="phoneOff" size={14} />
</button>
</div>
</div>
<div className="mini-call-controls" onClick={e => e.stopPropagation()}>
<button className={`mini-call-ctrl ${call.mic ? '' : 'danger active'}`} onClick={onToggleMic} title={call.mic ? 'Mute' : 'Unmute'}>
<Icon name={call.mic ? 'mic' : 'micOff'} size={15} />
</button>
<button className={`mini-call-ctrl ${call.cam ? 'active' : ''}`} onClick={onToggleCam} title="Camera">
<Icon name={call.cam ? 'video' : 'videoOff'} size={15} />
</button>
<button className={`mini-call-ctrl ${call.streaming ? 'active' : ''}`} onClick={onToggleScreen} title={call.streaming ? 'Stop stream' : 'Share screen'}>
<Icon name="screen" size={15} />
</button>
<button className={`mini-call-ctrl ${call.deaf ? 'danger active' : ''}`} onClick={onToggleDeaf} title="Deafen">
<Icon name="headphones" size={15} />
</button>
</div>
</div>
);
};
const RoomItem = ({ room, active, onClick }) => {
if (room.type === 'dm' || room.type === 'group') {
return (
<div className={`room-item ${active ? 'active' : ''} ${room.unread ? 'unread' : ''}`} onClick={onClick}>
<div className="avatar dm-avatar" style={{ background: room.color || 'var(--bg-3)' }}>
{room.avatar}
{room.presence && <span className={`presence-dot ${room.presence}`} />}
</div>
<div className="room-name">{room.name}</div>
{room.unread > 0 && <div className="room-badge mention">{room.unread}</div>}
<div className="hover-actions">
<div className="hover-action" title="Close"><Icon name="close" size={12} /></div>
</div>
</div>
);
}
return (
<div className={`room-item ${active ? 'active' : ''} ${room.unread ? 'unread' : ''}`} onClick={onClick}>
<div className="room-icon">{roomIcon(room.type)}</div>
<div className="room-name">{room.name}</div>
{room.type === 'voice' && room.users > 0 && (
<div className="badge" style={{ padding: '0 6px', fontSize: 10 }}>
<Icon name="users" size={10} /> <span style={{marginLeft: 3}}>{room.users}</span>
</div>
)}
{room.unread > 0 && <div className={`room-badge ${room.mention ? 'mention' : ''}`}>{room.unread}</div>}
<div className="hover-actions">
<div className="hover-action" title="Invite"><Icon name="plus" size={12} /></div>
<div className="hover-action" title="Settings"><Icon name="settings" size={12} /></div>
</div>
</div>
);
};
const SpaceMenu = ({ onClose }) => {
React.useEffect(() => {
const h = (e) => onClose();
setTimeout(() => document.addEventListener('click', h, { once: true }), 0);
return () => document.removeEventListener('click', h);
}, []);
return (
<div className="context-menu" style={{ top: 46, right: 10 }}>
<div className="context-item"><Icon name="users" size={14} /> Invite people <span className="shortcut">⌘I</span></div>
<div className="context-item"><Icon name="plus" size={14} /> Create room</div>
<div className="context-item"><Icon name="plus" size={14} /> Create category</div>
<div className="context-divider" />
<div className="context-item"><Icon name="settings" size={14} /> Space settings</div>
<div className="context-item"><Icon name="bell" size={14} /> Notification prefs</div>
<div className="context-item"><Icon name="link" size={14} /> Copy space link</div>
<div className="context-divider" />
<div className="context-item danger"><Icon name="close" size={14} /> Leave space</div>
</div>
);
};
const UserPanel = ({ user, onOpenProfile, onOpenSettings }) => {
const [mic, setMic] = React.useState(true);
const [head, setHead] = React.useState(true);
return (
<div className="user-panel">
<div className="avatar" onClick={onOpenProfile}>
{user.avatar}
<span className={`presence-dot ${user.status}`} />
</div>
<div className="user-info" onClick={onOpenProfile}>
<div className="user-name">{user.name}</div>
<div className="user-status">{user.statusText}</div>
</div>
<div className="user-actions">
<button className="icon-btn" title={mic ? 'Mute' : 'Unmute'} onClick={() => setMic(m => !m)}>
<Icon name={mic ? 'mic' : 'micOff'} size={15} />
</button>
<button className="icon-btn" title={head ? 'Deafen' : 'Undeafen'} onClick={() => setHead(h => !h)}>
<Icon name="headphones" size={15} />
</button>
<button className="icon-btn" title="Settings" onClick={onOpenSettings}>
<Icon name="settings" size={15} />
</button>
</div>
</div>
);
};
Object.assign(window, { SpacesRail, RoomsPanel, RoomItem, SpaceMenu, UserPanel, MiniCall });
+410
View File
@@ -0,0 +1,410 @@
/* ============================================
PYRAMID · Call system styles
Mini panel, stream preview, in-call tiles
============================================ */
/* Mini call panel (above user profile) */
.mini-call {
border-top: 1px solid var(--border);
background: var(--bg-2);
overflow: hidden;
flex-shrink: 0;
animation: slide-up 0.28s var(--spring-bounce);
}
.mini-call-connected {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: linear-gradient(90deg, hsl(142 60% 30% / 0.18), transparent 60%);
border-bottom: 1px solid var(--border);
}
.mini-call-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.mini-call-title {
font-size: 11px;
font-weight: 700;
color: var(--online);
letter-spacing: 0.04em;
text-transform: uppercase;
display: flex; align-items: center; gap: 5px;
}
.mini-call-title .live-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--online);
animation: pulse-ring 1.4s infinite;
box-shadow: 0 0 0 0 hsl(142 76% 50% / 0.6);
}
.mini-call-room {
font-size: 12px;
font-weight: 600;
color: var(--fg);
display: flex; align-items: center; gap: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.mini-call-meta {
font-size: 10px;
color: var(--fg-muted);
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
}
.mini-call-actions { display: flex; gap: 2px; }
.mini-call-actions .icon-btn { width: 26px; height: 26px; }
.mini-call-actions .icon-btn.leave { color: var(--danger); }
.mini-call-actions .icon-btn.leave:hover { background: rgba(229,72,77,0.14); color: var(--danger); }
.mini-call-controls {
display: flex;
gap: 4px;
padding: 8px 10px;
background: var(--bg-2);
}
.mini-call-ctrl {
flex: 1;
height: 32px;
border-radius: var(--r-sm);
background: var(--bg-3);
color: var(--fg);
display: flex; align-items: center; justify-content: center;
transition: all 0.18s var(--spring-bounce);
}
.mini-call-ctrl:hover { background: var(--bg-hover); transform: translateY(calc(-1px * var(--motion))); }
.mini-call-ctrl.active { background: var(--accent); color: var(--accent-fg); }
.mini-call-ctrl.danger.active { background: var(--danger); color: white; }
/* Stream mini preview (above mini-call) */
.stream-mini {
position: relative;
background: #000;
overflow: hidden;
border-bottom: 1px solid var(--border);
transition: height 0.3s var(--spring-bounce);
}
.stream-mini.collapsed {
height: 28px !important;
}
.stream-mini.collapsed .stream-mini-content { opacity: 0; }
.stream-mini-content {
position: absolute;
inset: 0;
background:
repeating-linear-gradient(45deg, #1a1a2e 0 10px, #16213e 10px 20px);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
}
.stream-mini-content::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, rgba(99,102,241,0.25), transparent 70%);
animation: shimmer 4s infinite linear;
background-size: 200% 100%;
}
.stream-mini-label {
position: relative;
z-index: 1;
color: white;
font-family: var(--font-mono);
font-size: 10px;
opacity: 0.7;
padding: 3px 8px;
background: rgba(0,0,0,0.4);
border-radius: 4px;
}
.stream-mini-bar {
position: absolute;
top: 0; left: 0; right: 0;
height: 28px;
padding: 0 10px;
display: flex;
align-items: center;
gap: 6px;
background: linear-gradient(180deg, rgba(0,0,0,0.85), rgba(0,0,0,0.55) 70%, transparent);
z-index: 2;
color: white;
font-size: 11px;
font-weight: 500;
}
.stream-mini.collapsed .stream-mini-bar {
background: var(--bg-3);
color: var(--fg);
}
.stream-mini-bar .streamer {
display: flex; align-items: center; gap: 6px;
flex: 1;
min-width: 0;
}
.stream-mini-bar .streamer .dot {
width: 6px; height: 6px; border-radius: 50%;
background: #ef4444;
animation: pulse-ring 1.4s infinite;
}
.stream-mini-bar .name {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.stream-mini-bar .chevron-btn {
width: 18px; height: 18px;
display: flex; align-items: center; justify-content: center;
color: inherit;
opacity: 0.7;
transition: transform 0.3s var(--spring-bounce), opacity 0.15s;
}
.stream-mini-bar .chevron-btn:hover { opacity: 1; }
.stream-mini.collapsed .stream-mini-bar .chevron-btn { transform: rotate(180deg); }
.stream-mini-btn {
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.12);
border-radius: 5px;
color: white;
transition: all 0.18s;
}
.stream-mini-btn:hover { background: rgba(255,255,255,0.22); }
.stream-mini.collapsed .stream-mini-btn { display: none; }
/* ==== Voice channel: stream-as-hero layout ==== */
.voice-layout {
display: flex;
flex-direction: column;
flex: 1;
gap: 16px;
min-height: 0;
}
.stream-hero {
flex: 1;
min-height: 280px;
border-radius: var(--r-lg);
overflow: hidden;
position: relative;
background: #000;
border: 1.5px solid var(--border);
transition: all 0.3s var(--spring-soft);
}
.stream-hero.fullscreen {
position: fixed;
inset: 0;
z-index: 2000;
border-radius: 0;
border: none;
}
.stream-hero-content {
position: absolute;
inset: 0;
background:
repeating-linear-gradient(45deg, #1a1a2e 0 14px, #16213e 14px 28px);
}
.stream-hero-content::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 30% 40%, rgba(99,102,241,0.35), transparent 60%),
radial-gradient(ellipse at 70% 70%, rgba(236,72,153,0.25), transparent 60%);
}
.stream-hero-center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: rgba(255,255,255,0.75);
}
.stream-hero-center .big-icon {
width: 64px; height: 64px;
border-radius: var(--r-lg);
background: rgba(255,255,255,0.08);
display: flex; align-items: center; justify-content: center;
border: 1px solid rgba(255,255,255,0.15);
backdrop-filter: blur(4px);
}
.stream-hero-center .big-label {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.04em;
}
.stream-hero-topbar {
position: absolute;
top: 0; left: 0; right: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
color: white;
z-index: 3;
background: linear-gradient(180deg, rgba(0,0,0,0.55), transparent);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.stream-hero:hover .stream-hero-topbar,
.stream-hero.fullscreen .stream-hero-topbar { opacity: 1; pointer-events: auto; }
.stream-live-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border-radius: var(--r-pill);
background: rgba(239,68,68,0.9);
color: white;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.stream-live-pill .dot {
width: 5px; height: 5px; border-radius: 50%;
background: white;
animation: pulse-ring 1.4s infinite;
}
.stream-hero-title {
font-size: 13px;
font-weight: 600;
display: flex; align-items: center; gap: 8px;
}
.stream-hero-viewers {
font-size: 11px;
opacity: 0.8;
font-variant-numeric: tabular-nums;
display: flex; align-items: center; gap: 4px;
}
.stream-hero-bottombar {
position: absolute;
bottom: 0; left: 0; right: 0;
padding: 16px;
display: flex;
align-items: center;
gap: 8px;
z-index: 3;
background: linear-gradient(0deg, rgba(0,0,0,0.7), transparent);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.stream-hero:hover .stream-hero-bottombar,
.stream-hero.fullscreen .stream-hero-bottombar { opacity: 1; pointer-events: auto; }
.stream-hero-btn {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.12);
backdrop-filter: blur(6px);
color: white;
border-radius: var(--r-sm);
transition: all 0.18s var(--spring-bounce);
}
.stream-hero-btn:hover {
background: rgba(255,255,255,0.22);
transform: scale(calc(1 + 0.08 * var(--motion)));
}
.stream-hero-btn.primary {
background: var(--accent);
color: var(--accent-fg);
}
.stream-hero-btn.primary:hover { background: var(--accent); filter: brightness(1.08); }
.stream-hero-spacer { flex: 1; }
.stream-quality-menu {
position: absolute;
bottom: 62px;
right: 16px;
background: rgba(15,15,22,0.96);
backdrop-filter: blur(12px);
border: 1px solid rgba(255,255,255,0.12);
border-radius: var(--r-base);
padding: 4px;
min-width: 200px;
z-index: 5;
animation: pop-in 0.18s var(--spring-bounce);
transform-origin: bottom right;
}
.stream-quality-item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: var(--r-sm);
color: rgba(255,255,255,0.85);
cursor: pointer;
font-size: 12px;
transition: background 0.12s;
}
.stream-quality-item:hover { background: rgba(255,255,255,0.1); }
.stream-quality-item .check-col { width: 14px; display: flex; color: var(--accent); }
.stream-quality-item .label { flex: 1; }
.stream-quality-item .hint {
font-size: 10px;
font-family: var(--font-mono);
opacity: 0.5;
}
.stream-quality-divider { height: 1px; background: rgba(255,255,255,0.1); margin: 4px 4px; }
.stream-quality-title {
padding: 6px 10px 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(255,255,255,0.5);
}
/* participants row below stream */
.voice-participants-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
max-height: 170px;
overflow: hidden;
}
.voice-tile.small {
aspect-ratio: 16/10;
min-height: 100px;
}
.voice-tile.small .voice-tile-avatar {
width: 44px; height: 44px;
font-size: 18px;
}
.voice-tile.small .voice-tile-name {
font-size: 11px;
padding: 2px 6px;
bottom: 6px; left: 6px;
}
/* DM header call buttons */
.chat-header-actions .call-btn {
position: relative;
}
.chat-header-actions .call-btn.video:hover { color: var(--accent); }
.chat-header-actions .call-btn.voice:hover { color: var(--online); }
/* Create/Join menu (expanded) */
.space-menu-wide { min-width: 240px; }
.space-menu-section {
padding: 6px 10px 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fg-dim);
}
/* Align user-panel baseline with composer bottom */
.user-panel {
padding: 8px 10px !important;
min-height: 52px;
}
.composer-wrap {
padding: 0 16px 12px !important;
}
+808
View File
@@ -0,0 +1,808 @@
/* ============================================
PYRAMID · Component styles
============================================ */
/* ==== Spaces Rail ==== */
.rail {
background: var(--bg-0);
border-right: 1px solid var(--border);
padding: 12px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
overflow-y: auto;
overflow-x: hidden;
position: relative;
}
.rail-home {
width: 48px; height: 48px;
border-radius: var(--r-lg);
background: linear-gradient(135deg, var(--accent) 0%, hsl(calc(var(--accent-h) + 20) var(--accent-s) calc(var(--accent-l) - 10%)) 100%);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
transition: all 0.25s var(--spring-bounce);
position: relative;
box-shadow: 0 4px 16px -4px var(--accent-glow);
}
.rail-home:hover { transform: translateY(calc(-2px * var(--motion))) rotate(calc(-3deg * var(--motion))); }
.rail-home:active { transform: scale(0.94); }
.rail-divider {
width: 32px; height: 2px;
background: var(--border);
border-radius: 2px;
margin: 4px 0;
}
.rail-item {
width: 48px; height: 48px;
border-radius: var(--r-lg);
background: var(--bg-2);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
position: relative;
transition: border-radius 0.28s var(--spring-bounce),
background 0.2s var(--spring-soft),
transform 0.2s var(--spring-bounce);
font-weight: 600;
font-size: 15px;
color: var(--fg);
overflow: visible;
}
.rail-item:hover {
border-radius: calc(var(--r-lg) * 0.7);
background: var(--bg-3);
transform: scale(calc(1 + 0.04 * var(--motion)));
}
.rail-item.active {
border-radius: calc(var(--r-lg) * 0.6);
}
.rail-item.active::before {
content: '';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 28px;
background: var(--accent);
border-radius: 0 3px 3px 0;
animation: slide-right 0.3s var(--spring-bounce);
}
/* Pyramid space icon: triangular shape */
.rail-item .pyramid-shape {
width: 30px; height: 28px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.rail-item.unread::after {
content: '';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 8px;
background: var(--fg);
border-radius: 0 2px 2px 0;
}
.rail-badge {
position: absolute;
bottom: -2px; right: -4px;
min-width: 18px; height: 18px;
padding: 0 5px;
background: var(--danger);
color: white;
border-radius: var(--r-pill);
font-size: 10px;
font-weight: 600;
display: flex; align-items: center; justify-content: center;
border: 2px solid var(--bg-0);
animation: bounce-in 0.4s var(--spring-bounce);
}
.rail-add {
width: 48px; height: 48px;
border-radius: var(--r-lg);
background: var(--bg-2);
color: var(--accent);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
font-size: 22px;
transition: all 0.25s var(--spring-bounce);
border: 1.5px dashed var(--border-strong);
}
.rail-add:hover {
border-radius: calc(var(--r-lg) * 0.7);
background: var(--accent-soft);
border-color: var(--accent);
transform: scale(calc(1 + 0.06 * var(--motion))) rotate(calc(90deg * var(--motion)));
}
/* Tooltip on rail items */
.rail-tooltip {
position: absolute;
left: 64px;
top: 50%;
transform: translateY(-50%) translateX(-4px);
background: var(--bg-0);
color: var(--fg);
padding: 6px 10px;
border-radius: var(--r-sm);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s, transform 0.15s var(--spring-bounce);
z-index: 100;
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
}
.rail-item:hover .rail-tooltip,
.rail-home:hover .rail-tooltip,
.rail-add:hover .rail-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
/* ==== Rooms panel ==== */
.rooms {
background: var(--bg-1);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.rooms-header {
height: 52px;
padding: 0 14px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.rooms-header-title {
font-weight: 600;
font-size: 15px;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rooms-header-icon {
width: 24px; height: 24px;
border-radius: 6px;
background: linear-gradient(135deg, var(--accent), hsl(calc(var(--accent-h) + 30) var(--accent-s) calc(var(--accent-l) - 8%)));
flex-shrink: 0;
}
.rooms-search {
padding: 10px 12px;
position: relative;
}
.rooms-search input {
width: 100%;
padding: 7px 10px 7px 30px;
border-radius: var(--r-sm);
background: var(--bg-2);
border: 1px solid transparent;
color: var(--fg);
font-size: 13px;
outline: none;
transition: all 0.2s var(--spring-soft);
}
.rooms-search input:focus {
background: var(--bg-0);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.rooms-search-icon {
position: absolute;
left: 21px;
top: 50%;
transform: translateY(-50%);
color: var(--fg-dim);
pointer-events: none;
}
.rooms-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px 12px;
}
.rooms-section {
margin-top: 8px;
}
.rooms-section-head {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--fg-dim);
cursor: pointer;
user-select: none;
}
.rooms-section-head:hover { color: var(--fg-muted); }
.rooms-section-head .chevron {
transition: transform 0.25s var(--spring-bounce);
}
.rooms-section-head.collapsed .chevron { transform: rotate(-90deg); }
.rooms-section-head .count {
margin-left: auto;
font-size: 11px;
color: var(--fg-dim);
font-weight: 500;
}
.rooms-section-head .add-btn {
opacity: 0;
transition: opacity 0.15s;
width: 18px; height: 18px;
display: flex; align-items: center; justify-content: center;
border-radius: 4px;
color: var(--fg-muted);
}
.rooms-section-head:hover .add-btn { opacity: 1; }
.rooms-section-head .add-btn:hover { background: var(--bg-hover); color: var(--accent); }
.room-item {
display: flex;
align-items: center;
gap: 8px;
padding: var(--row-pad-y) var(--row-pad-x);
border-radius: var(--r-sm);
cursor: pointer;
color: var(--fg-muted);
font-size: 14px;
font-weight: 500;
transition: all 0.18s var(--spring-soft);
position: relative;
min-height: 32px;
}
.room-item:hover {
background: var(--bg-hover);
color: var(--fg);
}
.room-item.active {
background: var(--accent-soft);
color: var(--fg);
font-weight: 600;
}
.room-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px; height: 18px;
background: var(--accent);
border-radius: 0 2px 2px 0;
}
.room-item.unread { color: var(--fg); font-weight: 600; }
.room-item.unread .room-icon { color: var(--fg); }
.room-icon {
width: 16px; height: 16px;
color: var(--fg-dim);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
}
.room-item.active .room-icon { color: var(--accent); }
.room-item:hover .room-icon { color: var(--fg); }
.room-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.room-badge {
min-width: 18px; height: 18px;
padding: 0 5px;
background: var(--accent);
color: var(--accent-fg);
border-radius: var(--r-pill);
font-size: 10px;
font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.room-badge.mention { background: var(--danger); color: white; }
.room-item .hover-actions {
display: none;
gap: 2px;
}
.room-item:hover .hover-actions { display: flex; }
.room-item:hover .room-badge { display: none; }
.room-item .hover-action {
width: 20px; height: 20px;
display: flex; align-items: center; justify-content: center;
color: var(--fg-dim);
border-radius: 4px;
}
.room-item .hover-action:hover { background: var(--bg-active); color: var(--fg); }
/* DM avatar in rooms list */
.room-item .dm-avatar {
width: 22px; height: 22px;
border-radius: 50%;
font-size: 10px;
position: relative;
}
.room-item .dm-avatar .presence-dot {
width: 8px; height: 8px;
border-width: 2px;
bottom: -1px; right: -1px;
}
/* ==== User panel at bottom of rooms ==== */
.user-panel {
padding: 8px 10px;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-2);
border-top: 1px solid var(--border);
flex-shrink: 0;
position: relative;
}
.user-panel .avatar {
width: 32px; height: 32px;
border-radius: var(--r-sm);
background: linear-gradient(135deg, #ff6b9d, #c471f5);
font-size: 13px;
position: relative;
}
.user-info { flex: 1; min-width: 0; cursor: pointer; }
.user-info:hover .user-name { color: var(--accent); }
.user-name {
font-weight: 600;
font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
transition: color 0.15s;
}
.user-status {
font-size: 11px;
color: var(--fg-muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
display: flex; align-items: center; gap: 4px;
}
.user-actions { display: flex; gap: 2px; }
/* ==== Chat main ==== */
.chat {
display: flex;
flex-direction: column;
background: var(--bg-1);
min-width: 0;
overflow: hidden;
}
.chat-header {
height: 52px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg-1);
z-index: 2;
}
.chat-header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 15px;
min-width: 0;
}
.chat-header-title .room-icon { color: var(--fg-dim); }
.chat-header-topic {
color: var(--fg-muted);
font-size: 13px;
padding-left: 12px;
border-left: 1px solid var(--border);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
flex: 1;
min-width: 0;
}
.chat-header-actions { display: flex; gap: 2px; margin-left: auto; }
/* Messages */
.messages {
flex: 1;
overflow-y: auto;
padding: 16px 0;
display: flex;
flex-direction: column;
}
.date-divider {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px 8px;
font-size: 11px;
font-weight: 600;
color: var(--fg-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.date-divider::before,
.date-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.msg-group {
padding: 4px 20px;
display: flex;
gap: 14px;
position: relative;
transition: background 0.1s;
}
.msg-group:hover { background: var(--bg-2); }
.msg-group + .msg-group { margin-top: 2px; }
.msg-group.has-avatar { margin-top: var(--msg-gap); }
.msg-avatar-col {
width: 38px;
flex-shrink: 0;
display: flex;
justify-content: center;
padding-top: 2px;
}
.msg-avatar {
width: 38px; height: 38px;
border-radius: var(--r-base);
font-size: 14px;
cursor: pointer;
transition: transform 0.2s var(--spring-bounce);
}
.msg-avatar:hover { transform: scale(calc(1 + 0.06 * var(--motion))); }
.msg-timestamp-col {
width: 38px;
font-size: 10px;
color: var(--fg-dim);
text-align: center;
padding-top: 4px;
font-variant-numeric: tabular-nums;
opacity: 0;
transition: opacity 0.15s;
}
.msg-group:hover .msg-timestamp-col { opacity: 1; }
.msg-body { flex: 1; min-width: 0; }
.msg-header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 2px;
}
.msg-author {
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.msg-author:hover { text-decoration: underline; }
.msg-time {
font-size: 11px;
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
}
.msg-text {
font-size: 14px;
color: var(--fg);
word-wrap: break-word;
line-height: 1.55;
}
.msg-text code {
font-family: var(--font-mono);
font-size: 12.5px;
background: var(--bg-3);
padding: 1px 5px;
border-radius: 4px;
border: 1px solid var(--border);
}
.msg-text a { color: var(--accent); text-decoration: none; }
.msg-text a:hover { text-decoration: underline; }
.msg-text .mention {
background: var(--accent-soft);
color: var(--accent);
padding: 0 4px;
border-radius: 3px;
font-weight: 500;
cursor: pointer;
}
.msg-text .mention:hover {
background: var(--accent);
color: var(--accent-fg);
}
.msg-image {
margin-top: 6px;
border-radius: var(--r-base);
overflow: hidden;
max-width: 420px;
height: 220px;
background: var(--bg-3);
border: 1px solid var(--border);
position: relative;
display: flex; align-items: center; justify-content: center;
cursor: zoom-in;
transition: transform 0.2s var(--spring-soft);
}
.msg-image:hover { transform: translateY(calc(-2px * var(--motion))); }
.msg-image-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-dim);
background: rgba(0,0,0,0.5);
padding: 3px 8px;
border-radius: 4px;
position: absolute;
bottom: 10px; left: 10px;
}
/* Reactions */
.reactions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.reaction {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: var(--r-pill);
font-size: 12px;
font-weight: 500;
color: var(--fg-muted);
cursor: pointer;
transition: all 0.2s var(--spring-bounce);
user-select: none;
}
.reaction:hover {
transform: translateY(calc(-1px * var(--motion))) scale(calc(1 + 0.04 * var(--motion)));
border-color: var(--accent);
}
.reaction.mine {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.reaction .emoji { font-size: 14px; }
.reaction .count { font-variant-numeric: tabular-nums; }
.reaction-add {
width: 24px; height: 22px;
padding: 0;
display: inline-flex; align-items: center; justify-content: center;
background: var(--bg-3);
border: 1px dashed var(--border-strong);
border-radius: var(--r-pill);
color: var(--fg-dim);
cursor: pointer;
opacity: 0;
transition: all 0.2s var(--spring-bounce);
}
.msg-group:hover .reaction-add { opacity: 1; }
.reaction-add:hover { color: var(--accent); border-color: var(--accent); transform: rotate(calc(90deg * var(--motion))); }
/* Hover actions popup on message */
.msg-actions {
position: absolute;
top: -14px;
right: 32px;
background: var(--bg-0);
border: 1px solid var(--border);
border-radius: var(--r-base);
padding: 2px;
display: none;
gap: 1px;
box-shadow: var(--shadow-lg);
z-index: 3;
}
.msg-group:hover .msg-actions {
display: flex;
animation: pop-in 0.18s var(--spring-bounce);
}
.msg-action {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--r-sm);
color: var(--fg-muted);
transition: all 0.15s;
position: relative;
}
.msg-action:hover { background: var(--bg-hover); color: var(--fg); }
.msg-action.emoji-btn:hover { color: var(--accent); }
/* Composer */
.composer-wrap {
padding: 0 16px 12px;
flex-shrink: 0;
}
.composer {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: calc(var(--r-base) + 4px);
transition: all 0.2s var(--spring-soft);
}
.composer:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.composer-input-row {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px 4px 4px;
min-height: 40px;
}
.composer-input-row .composer-plus {
width: 30px; height: 30px;
flex-shrink: 0;
}
.composer-inline-tools {
display: flex;
align-items: center;
gap: 0;
flex-shrink: 0;
padding-right: 2px;
border-right: 1px solid var(--border);
margin-right: 4px;
}
.composer-inline-tools .icon-btn {
width: 26px; height: 26px;
}
.composer-input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--fg);
font-size: 14px;
resize: none;
min-height: 22px;
max-height: 200px;
padding: 4px 2px;
line-height: 1.5;
}
.composer-input::placeholder { color: var(--fg-dim); }
.composer-send {
width: 30px; height: 30px;
border-radius: calc(var(--r-sm) - 2px);
background: var(--bg-3);
color: var(--fg-dim);
display: flex; align-items: center; justify-content: center;
transition: all 0.2s var(--spring-bounce);
flex-shrink: 0;
}
.composer-send.active {
background: var(--accent);
color: var(--accent-fg);
transform: scale(calc(1 + 0.05 * var(--motion)));
}
.composer-send.active:hover {
transform: scale(calc(1 + 0.1 * var(--motion))) rotate(calc(-15deg * var(--motion)));
box-shadow: 0 4px 12px -2px var(--accent-glow);
}
.composer-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 0 10px 8px;
}
.composer-toolbar .icon-btn { width: 28px; height: 28px; }
.composer-hint {
margin-left: auto;
font-size: 11px;
color: var(--fg-dim);
font-family: var(--font-mono);
}
/* ==== Members panel (right) ==== */
.members {
background: var(--bg-1);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.members-header {
height: 52px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 14px;
}
.members-header .count { color: var(--fg-dim); font-weight: 500; }
.members-list { flex: 1; overflow-y: auto; padding: 8px; }
.member-section-head {
padding: 8px 8px 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--fg-dim);
}
.member-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background 0.15s;
}
.member-item:hover { background: var(--bg-hover); }
.member-item .avatar {
width: 28px; height: 28px;
border-radius: var(--r-sm);
font-size: 11px;
position: relative;
}
.member-info { flex: 1; min-width: 0; }
.member-name {
font-size: 13px;
font-weight: 500;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.member-note {
font-size: 11px;
color: var(--fg-dim);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.member-item.offline .avatar { opacity: 0.5; }
.member-item.offline .member-name { color: var(--fg-muted); }
/* ==== Toolbar (top-right in chat) ==== */
.topic-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: var(--r-pill);
background: var(--bg-2);
border: 1px solid var(--border);
font-size: 12px;
color: var(--fg-muted);
}
+280
View File
@@ -0,0 +1,280 @@
/* Shared data for the Pyramid mockup */
window.PYRAMID_DATA = {
user: {
name: 'mira',
handle: '@mira:pyramid.chat',
avatar: 'M',
status: 'online',
statusText: 'in the zone 🎨',
},
spaces: [
{ id: 'home', kind: 'home', name: 'Home', unread: 0 },
{ id: 'atelier', name: 'Atelier', short: 'A', color: '#f59e0b', unread: 3, mention: false, active: true },
{ id: 'friends', name: 'Friends', short: 'F', color: '#ec4899', unread: 12, mention: true },
{ id: 'music', name: 'soundscape', short: 'S', color: '#8b5cf6', unread: 0 },
{ id: 'readclub', name: 'Read Club', short: 'R', color: '#06b6d4', unread: 0 },
{ id: 'devlab', name: 'Dev Lab', short: 'D', color: '#10b981', unread: 1 },
],
rooms: {
atelier: {
name: 'Atelier',
sections: [
{
id: 'info',
title: 'Info',
rooms: [
{ id: 'welcome', name: 'welcome', type: 'text' },
{ id: 'rules', name: 'rules-and-vibes', type: 'text' },
{ id: 'announcements', name: 'announcements', type: 'announce', unread: 2 },
],
},
{
id: 'chat',
title: 'Rooms',
rooms: [
{ id: 'general', name: 'general', type: 'text', active: true },
{ id: 'crit', name: 'critique-circle', type: 'text', unread: 5, mention: true },
{ id: 'wip', name: 'work-in-progress', type: 'text', unread: 1 },
{ id: 'resources', name: 'resources', type: 'text' },
{ id: 'offtopic', name: 'off-topic', type: 'text' },
],
},
{
id: 'voice',
title: 'Voice',
rooms: [
{ id: 'lounge', name: 'The Lounge', type: 'voice', users: 4 },
{ id: 'focus', name: 'Focus Room', type: 'voice' },
{ id: 'movie', name: 'Movie Night', type: 'voice' },
],
},
],
dms: [],
},
friends: {
name: 'Friends & DMs',
sections: [
{
id: 'dms',
title: 'Direct Messages',
rooms: [
{ id: 'dm-juno', name: 'juno', type: 'dm', avatar: 'J', color: '#06b6d4', presence: 'online', unread: 2, active: true },
{ id: 'dm-kai', name: 'kai', type: 'dm', avatar: 'K', color: '#f43f5e', presence: 'away' },
{ id: 'dm-ren', name: 'ren', type: 'dm', avatar: 'R', color: '#10b981', presence: 'busy' },
{ id: 'dm-sachi', name: 'sachi ✨', type: 'dm', avatar: 'S', color: '#a855f7', presence: 'online' },
{ id: 'dm-lo', name: 'lo', type: 'dm', avatar: 'L', color: '#f59e0b', presence: 'offline' },
{ id: 'dm-ezra', name: 'ezra', type: 'dm', avatar: 'E', color: '#3b82f6', presence: 'offline' },
],
},
{
id: 'groups',
title: 'Group Chats',
rooms: [
{ id: 'g-tues', name: 'tuesday night', type: 'group', avatar: '🌙' },
{ id: 'g-trip', name: 'lisbon trip ✈️', type: 'group', avatar: '✈️', unread: 8 },
],
},
],
},
},
messagesByRoom: {
general: [
{
id: 'm1',
author: 'juno',
avatar: 'J',
color: '#06b6d4',
time: '2:14 PM',
text: 'morning! just wrapped that risograph print you all were curious about last week',
reactions: [
{ emoji: '🌿', count: 4, mine: true },
{ emoji: '👀', count: 2 },
],
},
{
id: 'm2',
author: 'juno',
continuation: true,
time: '2:14 PM',
text: 'turned out way better than expected — the mis-registration is doing a LOT of heavy lifting',
},
{
id: 'm3',
author: 'juno',
continuation: true,
time: '2:15 PM',
image: 'risograph print · 2 colors · A3',
reactions: [
{ emoji: '🔥', count: 7, mine: true },
{ emoji: '💚', count: 3 },
{ emoji: '🫠', count: 1 },
],
},
{
id: 'm4',
author: 'sachi',
avatar: 'S',
color: '#a855f7',
time: '2:18 PM',
text: 'ok the teal bleeding into the coral here is *chefskiss* — what paper did you end up using?',
},
{
id: 'm5',
author: 'ren',
avatar: 'R',
color: '#10b981',
time: '2:22 PM',
text: 'this is making me want to finally try riso. @juno do you know anywhere nearby that rents time on one?',
mentions: ['juno'],
},
{
id: 'm6',
author: 'juno',
avatar: 'J',
color: '#06b6d4',
time: '2:24 PM',
text: 'there\'s a little studio on baker street — they do 4hr blocks for 30 bucks, i can drop the link in #resources',
reactions: [{ emoji: '🙏', count: 2 }],
},
{
id: 'm7',
author: 'mira',
avatar: 'M',
color: '#ff6b9d',
time: '2:31 PM',
text: 'oh dope, add me to that excursion whenever you two go',
reactions: [{ emoji: '✋', count: 3, mine: true }],
},
{
id: 'm8',
author: 'kai',
avatar: 'K',
color: '#f43f5e',
time: '2:40 PM',
text: 'side note — anyone else\'s composer showing `typing…` ghosts that won\'t go away? third time this week',
},
{
id: 'm9',
author: 'kai',
continuation: true,
time: '2:40 PM',
text: 'not a huge deal, just curious if it\'s a me problem',
},
{
id: 'm10',
author: 'ezra',
avatar: 'E',
color: '#3b82f6',
time: '2:44 PM',
text: 'confirmed on my end too — filed a tiny bug report in #dev-lab yesterday',
},
],
'dm-juno': [
{
id: 'dm1',
author: 'juno',
avatar: 'J',
color: '#06b6d4',
time: '11:02 AM',
text: 'heyy — you around later? wanted to pick your brain about the studio visit',
},
{
id: 'dm2',
author: 'mira',
avatar: 'M',
color: '#ff6b9d',
time: '11:14 AM',
text: 'yes!! free after 4, should i come by or zoom?',
reactions: [{ emoji: '💛', count: 1 }],
},
{
id: 'dm3',
author: 'juno',
avatar: 'J',
color: '#06b6d4',
time: '11:16 AM',
text: 'come by — i finally got the kettle working so tea is officially on the table',
},
{
id: 'dm4',
author: 'juno',
continuation: true,
time: '11:16 AM',
text: 'also i want to show you the new zine layout in person, screenshots don\'t do it justice',
},
{
id: 'dm5',
author: 'mira',
avatar: 'M',
color: '#ff6b9d',
time: '11:18 AM',
text: 'sold. bringing pastries',
reactions: [{ emoji: '🥐', count: 1 }, { emoji: '🥹', count: 1, mine: true }],
},
{
id: 'dm6',
author: 'juno',
avatar: 'J',
color: '#06b6d4',
time: '1:45 PM',
text: 'btw — thought you\'d like this piece about marginalia in printing. very you.',
},
{
id: 'dm7',
author: 'juno',
continuation: true,
time: '1:46 PM',
text: 'https://types.place/marginalia-as-protest',
},
],
},
membersByRoom: {
general: {
online: [
{ name: 'juno', color: '#06b6d4', avatar: 'J', note: 'playing with inks', status: 'online' },
{ name: 'sachi', color: '#a855f7', avatar: 'S', note: 'reading', status: 'online' },
{ name: 'mira', color: '#ff6b9d', avatar: 'M', note: 'in the zone 🎨', status: 'online', self: true },
{ name: 'ren', color: '#10b981', avatar: 'R', status: 'busy' },
{ name: 'kai', color: '#f43f5e', avatar: 'K', status: 'away' },
{ name: 'ezra', color: '#3b82f6', avatar: 'E', note: 'debugging', status: 'online' },
],
offline: [
{ name: 'lo', color: '#f59e0b', avatar: 'L', status: 'offline' },
{ name: 'theo', color: '#64748b', avatar: 'T', status: 'offline' },
{ name: 'nia', color: '#84cc16', avatar: 'N', status: 'offline' },
],
},
},
emojiCategories: [
{
key: 'recent', icon: '🕒', title: 'Frequently used',
emojis: ['🔥', '💛', '🥹', '👀', '🙏', '✨', '🌿', '🫠']
},
{
key: 'smileys', icon: '😀', title: 'Smileys & People',
emojis: ['😀','😃','😄','😁','😆','😅','🤣','😂','🙂','🙃','😉','😊','😇','🥰','😍','🤩','😘','😗','🥲','☺️','😚','😙','🥲','😋','😛','😜','🤪','😝','🤑','🤗','🫡','🤫','🤔','🫢','🫠','🤐','🤨','😐','😑','😶']
},
{
key: 'nature', icon: '🌿', title: 'Nature',
emojis: ['🌿','🌱','🌵','🌴','🌳','🌲','🍀','🌾','🌷','🌸','🌼','🌻','🌺','🌹','🥀','💐','🌞','🌝','🌚','🌛','🌜','⭐','🌟','✨','💫','☄️','☀️']
},
{
key: 'food', icon: '🥐', title: 'Food & Drink',
emojis: ['🥐','🥖','🍞','🥨','🧀','🥚','🍳','🧈','🥞','🧇','🥓','🥩','🍗','🍖','🌭','🍔','🍟','🍕','🥪','🥙','🧆','🌮','🌯','🥗','🥘','🫕']
},
{
key: 'activity', icon: '🎨', title: 'Activity',
emojis: ['🎨','🖌️','🖊️','✏️','📝','📚','📖','🎭','🎬','🎤','🎧','🎼','🎹','🥁','🎸','🎺','🎻','🎮','🕹️','🎲','🧩']
},
{
key: 'symbols', icon: '💛', title: 'Symbols',
emojis: ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💘','💝']
},
],
};
+136
View File
@@ -0,0 +1,136 @@
# Pyramid · Media Players (Flutter)
Player-Skelette im Pyramid-Stil — **Audio (4 Varianten)** und **Video (3 Varianten)** als wiederverwendbare Flutter-Widgets. Dies ist ein **UI-Gerüst ohne echte Medienwiedergabe**`position`, `duration`, `buffered`, `playing` sind reine Props. Zum Anschluss an echte Medien siehe „Mit echtem Playback verbinden" weiter unten.
---
## 📁 Dateien
```
flutter_export/
├── pubspec.yaml
└── lib/
├── pyramid_theme.dart ← Farb-, Typo-, Radius-Tokens (Single Source of Truth)
├── pyramid_audio_player.dart ← PyramidAudioPlayer + AudioPlayerVariant
├── pyramid_video_player.dart ← PyramidVideoPlayer + VideoPlayerVariant
└── example_app.dart ← Showcase mit allen Varianten (zum Reinkopieren)
```
Kopiere die drei `pyramid_*.dart` Dateien in dein Projekt (z. B. `lib/widgets/players/`) und importiere sie wie üblich.
---
## 🎨 Theme
Alle Player ziehen Farben/Typo aus `PyramidTheme`. Default ist **Dark + Amber**.
```dart
PyramidTheme.dark = true; // false → Light
PyramidTheme.setAccent(42, 95, 58); // HSL — Default Pyramid Amber
// Weitere Akzente: 280/70/60 (Violet), 160/65/50 (Mint), 0/75/60 (Coral)
```
**Fonts:** Theme erwartet `Geist` und `GeistMono`. Ohne Asset-Einbindung in `pubspec.yaml` fällt Flutter auf System-Sans/Mono zurück (immer noch ok). Die Font-Asset-Konfig ist in `pubspec.yaml` auskommentiert vorbereitet.
---
## 🎧 Audio Player
```dart
PyramidAudioPlayer(
variant: AudioPlayerVariant.gracile,
title: 'test track',
artist: 'pyramid · demo',
duration: const Duration(minutes: 3, seconds: 24),
position: _position, // ValueNotifier oder State
playing: _playing,
onTogglePlay: () => setState(() => _playing = !_playing),
onSeek: (Duration to) => setState(() => _position = to),
onSkipBack: () { /* 15s */ },
onSkipForward: () { /* +15s */ },
onSpeedChange: (double s) { /* 1.0 / 1.5 / 2.0 */ },
)
```
### Wann welche Variante?
| Variante | Wann benutzen | Größe |
|---|---|---|
| **`AudioPlayerVariant.defaultBars`** | Standard-Audio-Anhang in einer Nachricht. Robuste Bar-Waveform — wirkt „solide", für Musik-Snippets. | ≥ 380px breit |
| **`AudioPlayerVariant.gracile`** ⭐ | **Empfehlung für Voice-Notes und längere Audio-Dateien.** Filigrane Hairline-Waveform — passt am besten zum Pyramid-Stil. | ≥ 380px breit |
| **`AudioPlayerVariant.compact`** | **In-Chat Inline-Embed**, wenn Platz knapp ist (Reply-Quote, Thread-Vorschau, Side-Panel). Eine Zeile. | ≥ 320px breit |
| **`AudioPlayerVariant.mono`** | Spezielle Räume mit Terminal-/Editorial-Identität (z. B. `#announce`, „archive", Devs-Spaces). Mono-Font, harte Kanten. Sparsam einsetzen. | ≥ 380px breit |
---
## 🎬 Video Player
```dart
PyramidVideoPlayer(
variant: VideoPlayerVariant.defaultHover,
title: 'test clip',
subtitle: 'pyramid · 1080p',
duration: const Duration(minutes: 4, seconds: 12),
position: _videoPos,
buffered: 0.65, // 0..1
playing: _playing,
onTogglePlay: () => setState(() => _playing = !_playing),
onSeek: (Duration to) => setState(() => _videoPos = to),
onSpeedChange: (double s) { /* 0.5 / 1 / 1.25 / 1.5 / 2 */ },
onQualityChange: (String q) { /* Auto / 1080p / 720p / 480p / 360p */ },
onFullscreen: () { /* Navigator push fullscreen route */ },
)
```
### Wann welche Variante?
| Variante | Wann benutzen | Aspect | Chrome |
|---|---|---|---|
| **`VideoPlayerVariant.defaultHover`** ⭐ | **Standard-Video-Embed im Chat.** Player nimmt volle Breite ein, Controls erscheinen bei Hover/Tap. | 16:10 | hover/auto-hide |
| **`VideoPlayerVariant.minimal`** | **Inline-Vorschau** in dichten Layouts (Reply, Thread-Liste). Controls immer sichtbar, kompakter. | 16:9 | always-on |
| **`VideoPlayerVariant.framed`** | **Galerie-/Karten-Layouts** oder Posts mit Caption darunter. Stage in Card, Controls auf hellem Card-BG außerhalb des schwarzen Stages. | 16:9 + Card | always-on (Card) |
---
## 🔌 Mit echtem Playback verbinden
Diese Widgets sind reine UI-Mockups. Für echte Wiedergabe binde z. B. das offizielle [`video_player`](https://pub.dev/packages/video_player) (Video) oder [`just_audio`](https://pub.dev/packages/just_audio) (Audio) Package an:
```dart
// Video-Beispiel mit video_player
final controller = VideoPlayerController.networkUrl(Uri.parse(url))..initialize();
controller.addListener(() => setState(() {}));
// im build():
PyramidVideoPlayer(
variant: VideoPlayerVariant.defaultHover,
title: 'remote clip',
duration: controller.value.duration,
position: controller.value.position,
buffered: controller.value.buffered.isEmpty ? 0
: controller.value.buffered.last.end.inMilliseconds /
controller.value.duration.inMilliseconds,
playing: controller.value.isPlaying,
onTogglePlay: () => controller.value.isPlaying ? controller.pause() : controller.play(),
onSeek: (to) => controller.seekTo(to),
onSpeedChange: (s) => controller.setPlaybackSpeed(s),
)
```
**Stage rendern:** Aktuell zeigt der Player einen Pyramid-Triangle als Platzhalter. Ersetze in `pyramid_video_player.dart` die `_stage()`-Methode durch dein `VideoPlayer(controller)` Widget.
---
## 🧪 Showcase
`example_app.dart` zeigt alle 7 Player-Varianten in einem scrollbaren Dark-Layout. Direkt ausführbar via `flutter run -t lib/example_app.dart`.
---
## ✅ Checklist beim Einbau
- [ ] `pyramid_theme.dart` + Audio + Video kopiert
- [ ] `PyramidTheme.dark` und `setAccent(...)` in App-State gesetzt
- [ ] (optional) Geist + Geist Mono in `pubspec.yaml` eingebunden
- [ ] Player mit echtem Audio-/Video-Controller verdrahtet
- [ ] Skip-Intervall an Produkt-Konvention angepasst (Material hat `replay_10` / `forward_10` — wenn du wirklich ±15s willst, die Icons mit `Stack(text)` ersetzen)
+159
View File
@@ -0,0 +1,159 @@
// example_app.dart
// Pyramid Players · Showcase / Test-Harness
//
// Zeigt alle Player-Varianten in einem scrollbaren Dark-Layout.
// Drop-in-fähig: füge die drei Pyramid-Dateien (theme + audio + video)
// in dein Projekt und referenziere sie wie unten.
import 'package:flutter/material.dart';
import 'pyramid_theme.dart';
import 'pyramid_audio_player.dart';
import 'pyramid_video_player.dart';
void main() => runApp(const PyramidShowcaseApp());
class PyramidShowcaseApp extends StatelessWidget {
const PyramidShowcaseApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Pyramid · Players',
theme: ThemeData(
scaffoldBackgroundColor: PyramidTheme.bg0,
fontFamily: PyramidTheme.fontSans,
useMaterial3: true,
),
home: const _Showcase(),
);
}
}
class _Showcase extends StatefulWidget {
const _Showcase();
@override
State<_Showcase> createState() => _ShowcaseState();
}
class _ShowcaseState extends State<_Showcase> {
bool _playing = true;
Duration _audioPos = const Duration(minutes: 1, seconds: 11);
final _audioDur = const Duration(minutes: 3, seconds: 24);
Duration _videoPos = const Duration(minutes: 1, seconds: 28);
final _videoDur = const Duration(minutes: 4, seconds: 12);
void _toggle() => setState(() => _playing = !_playing);
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(48, 48, 48, 120),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('PYRAMID · MEDIA PLAYERS',
style: TextStyle(fontFamily: PyramidTheme.fontMono, fontSize: 10, letterSpacing: 1.4, color: PyramidTheme.fgDim)),
const SizedBox(height: 6),
Text('Audio + Video — Flutter', style: TextStyle(color: PyramidTheme.fg, fontSize: 28, fontWeight: FontWeight.w500)),
const SizedBox(height: 40),
_section('AUDIO'),
_grid([
_frame('01 · Default', 'chunky bars',
PyramidAudioPlayer(
variant: AudioPlayerVariant.defaultBars,
title: 'test track', artist: 'pyramid · demo',
duration: _audioDur, position: _audioPos,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _audioPos = p),
)),
_frame('02 · Gracile', 'hairline wave',
PyramidAudioPlayer(
variant: AudioPlayerVariant.gracile,
title: 'test track', artist: 'pyramid · demo',
duration: _audioDur, position: _audioPos,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _audioPos = p),
)),
_frame('03 · Compact', 'in-chat',
PyramidAudioPlayer(
variant: AudioPlayerVariant.compact,
title: 'test track', artist: 'pyramid · demo',
duration: _audioDur, position: _audioPos,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _audioPos = p),
)),
_frame('04 · Mono', 'terminal',
PyramidAudioPlayer(
variant: AudioPlayerVariant.mono,
title: 'test.wav', artist: 'pyramid · demo',
duration: _audioDur, position: _audioPos,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _audioPos = p),
)),
]),
const SizedBox(height: 40),
_section('VIDEO'),
_grid([
_frame('01 · Default', 'hover-chrome',
PyramidVideoPlayer(
variant: VideoPlayerVariant.defaultHover,
title: 'test clip', subtitle: 'pyramid · 1080p',
duration: _videoDur, position: _videoPos, buffered: 0.65,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _videoPos = p),
)),
_frame('02 · Minimal', 'always-visible',
PyramidVideoPlayer(
variant: VideoPlayerVariant.minimal,
title: 'test clip', subtitle: 'pyramid · 1080p',
duration: _videoDur, position: _videoPos, buffered: 0.65,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _videoPos = p),
)),
_frame('03 · Framed', 'editorial',
PyramidVideoPlayer(
variant: VideoPlayerVariant.framed,
title: 'test clip', subtitle: 'pyramid · 1080p',
duration: _videoDur, position: _videoPos, buffered: 0.65,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _videoPos = p),
)),
]),
]),
),
),
);
}
Widget _section(String label) => Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Container(
padding: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: PyramidTheme.border))),
child: Text(label,
style: TextStyle(fontFamily: PyramidTheme.fontMono, fontSize: 10, letterSpacing: 1.8, color: PyramidTheme.fgDim)),
),
);
Widget _frame(String label, String sub, Widget child) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(label, style: TextStyle(fontFamily: PyramidTheme.fontMono, fontSize: 10, letterSpacing: 1.4, color: PyramidTheme.fgMuted)),
Text(sub, style: TextStyle(fontFamily: PyramidTheme.fontMono, fontSize: 10, letterSpacing: 1.4, color: PyramidTheme.fgDim)),
]),
),
child,
],
);
Widget _grid(List<Widget> items) => Wrap(
spacing: 28, runSpacing: 28,
children: items.map((i) => SizedBox(width: 420, child: i)).toList(),
);
}
@@ -0,0 +1,477 @@
// pyramid_audio_player.dart
// Pyramid · Audio Player
//
// Vier Varianten:
// AudioPlayerVariant.defaultBars → Karte mit chunky-bar Waveform (wie HTML "01 · Default")
// AudioPlayerVariant.gracile → Karte mit hairline-Waveform (graziler, schicker)
// AudioPlayerVariant.compact → Eine Zeile, für In-Chat (Voice-Note Stil)
// AudioPlayerVariant.mono → Terminal-Look, Mono-Font, harte Kanten
//
// Verwendung:
// PyramidAudioPlayer(
// variant: AudioPlayerVariant.gracile,
// title: 'test track',
// artist: 'pyramid · demo',
// duration: Duration(minutes: 3, seconds: 24),
// position: Duration(minutes: 1, seconds: 11),
// playing: true,
// onTogglePlay: () { ... },
// onSeek: (Duration to) { ... },
// );
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'pyramid_theme.dart';
enum AudioPlayerVariant { defaultBars, gracile, compact, mono }
class PyramidAudioPlayer extends StatefulWidget {
final AudioPlayerVariant variant;
final String title;
final String artist;
final Duration duration;
final Duration position;
final bool playing;
final double volume; // 0..1
final VoidCallback? onTogglePlay;
final ValueChanged<Duration>? onSeek;
final ValueChanged<double>? onVolumeChange;
final ValueChanged<double>? onSpeedChange; // 1.0, 1.5, 2.0
final VoidCallback? onSkipBack;
final VoidCallback? onSkipForward;
final VoidCallback? onLike;
final VoidCallback? onMore;
const PyramidAudioPlayer({
super.key,
this.variant = AudioPlayerVariant.gracile,
required this.title,
required this.artist,
required this.duration,
required this.position,
this.playing = false,
this.volume = 0.7,
this.onTogglePlay,
this.onSeek,
this.onVolumeChange,
this.onSpeedChange,
this.onSkipBack,
this.onSkipForward,
this.onLike,
this.onMore,
});
@override
State<PyramidAudioPlayer> createState() => _PyramidAudioPlayerState();
}
class _PyramidAudioPlayerState extends State<PyramidAudioPlayer> {
double speed = 1.0;
static const speeds = [1.0, 1.5, 2.0];
double get progress => widget.duration.inMilliseconds == 0
? 0
: widget.position.inMilliseconds / widget.duration.inMilliseconds;
String _fmt(Duration d) {
final m = d.inMinutes;
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
if (widget.variant == AudioPlayerVariant.compact) return _buildCompact();
return _buildCard();
}
// ─── Compact (in-chat) ─────────────────────────────────────────────
Widget _buildCompact() {
return Container(
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
decoration: BoxDecoration(
color: PyramidTheme.bg1,
border: Border.all(color: PyramidTheme.border),
borderRadius: BorderRadius.circular(PyramidTheme.rLg),
),
child: Row(
children: [
_Artwork(size: 36),
const SizedBox(width: 10),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.title, style: PyramidTheme.title.copyWith(fontSize: 13), overflow: TextOverflow.ellipsis),
Text(widget.artist, style: PyramidTheme.subtitle.copyWith(fontSize: 11), overflow: TextOverflow.ellipsis),
],
),
),
const SizedBox(width: 12),
_PlayButton(playing: widget.playing, onTap: widget.onTogglePlay, size: 32),
const SizedBox(width: 12),
Text(_fmt(widget.position), style: PyramidTheme.mono),
const SizedBox(width: 8),
Expanded(child: _Scrubber(progress: progress, onSeek: _seekFromFraction)),
const SizedBox(width: 8),
Text(_fmt(widget.duration), style: PyramidTheme.mono),
],
),
);
}
// ─── Default / Gracile / Mono ──────────────────────────────────────
Widget _buildCard() {
final isMono = widget.variant == AudioPlayerVariant.mono;
final radius = isMono ? 0.0 : PyramidTheme.rLg;
return Container(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
decoration: BoxDecoration(
color: isMono ? PyramidTheme.bg0 : PyramidTheme.bg1,
border: Border.all(color: isMono ? PyramidTheme.borderStrong : PyramidTheme.border),
borderRadius: BorderRadius.circular(radius),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Head
Row(
children: [
_Artwork(size: 48, mono: isMono),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
isMono ? widget.title.toUpperCase() : widget.title,
style: isMono
? PyramidTheme.monoStrong.copyWith(fontSize: 12, color: PyramidTheme.fg, letterSpacing: 0.3)
: PyramidTheme.title,
overflow: TextOverflow.ellipsis,
),
Text(
isMono ? widget.artist.toUpperCase() : widget.artist,
style: isMono
? PyramidTheme.mono.copyWith(fontSize: 10, letterSpacing: 0.8)
: PyramidTheme.subtitle,
overflow: TextOverflow.ellipsis,
),
],
),
),
_IconBtn(icon: Icons.favorite_border, onTap: widget.onLike),
_IconBtn(icon: Icons.more_horiz, onTap: widget.onMore),
],
),
const SizedBox(height: 14),
// Waveform
GestureDetector(
onTapDown: (d) => _seekFromTap(d.localPosition.dx, context.size?.width ?? 1),
child: SizedBox(
height: widget.variant == AudioPlayerVariant.gracile ? 44 : 40,
child: widget.variant == AudioPlayerVariant.gracile
? CustomPaint(painter: _GracileWavePainter(progress: progress), size: Size.infinite)
: CustomPaint(painter: _ChunkyBarsPainter(progress: progress), size: Size.infinite),
),
),
const SizedBox(height: 8),
// Times
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_fmt(widget.position), style: PyramidTheme.mono),
Text('${_fmt(widget.duration - widget.position)}', style: PyramidTheme.mono),
],
),
const SizedBox(height: 14),
// Controls row
Row(
children: [
_IconBtn(icon: Icons.volume_up_outlined, onTap: null),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: _MiniBar(value: widget.volume),
),
const Spacer(),
_IconBtn(icon: Icons.replay_10, onTap: widget.onSkipBack), // ≈ 15 (Material has 10/30; close enough)
const SizedBox(width: 4),
_PlayButton(playing: widget.playing, onTap: widget.onTogglePlay, size: 40, mono: isMono),
const SizedBox(width: 4),
_IconBtn(icon: Icons.forward_10, onTap: widget.onSkipForward),
const Spacer(),
...speeds.map((s) => _SpeedPill(
label: '${s == s.toInt() ? s.toInt() : s}x',
active: speed == s,
mono: isMono,
onTap: () {
setState(() => speed = s);
widget.onSpeedChange?.call(s);
},
)),
],
),
],
),
);
}
void _seekFromFraction(double f) =>
widget.onSeek?.call(Duration(milliseconds: (widget.duration.inMilliseconds * f).round()));
void _seekFromTap(double x, double w) {
if (w <= 0) return;
_seekFromFraction((x / w).clamp(0.0, 1.0));
}
}
// ───────────────────────── Building blocks ─────────────────────────
class _Artwork extends StatelessWidget {
final double size;
final bool mono;
const _Artwork({required this.size, this.mono = false});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(mono ? 0 : PyramidTheme.rSm),
gradient: mono
? null
: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
HSLColor.fromAHSL(1, PyramidTheme.accentH, PyramidTheme.accentS / 100, (PyramidTheme.accentL - 10) / 100).toColor(),
HSLColor.fromAHSL(1, PyramidTheme.accentH + 30, PyramidTheme.accentS / 100, (PyramidTheme.accentL - 20) / 100).toColor(),
],
),
color: mono ? PyramidTheme.accent : null,
),
child: Center(
child: CustomPaint(
size: Size(size * 0.45, size * 0.42),
painter: _TrianglePainter(color: mono ? PyramidTheme.accentFg : Colors.white.withOpacity(0.9)),
),
),
);
}
}
class _TrianglePainter extends CustomPainter {
final Color color;
_TrianglePainter({required this.color});
@override
void paint(Canvas canvas, Size s) {
final p = Paint()..color = color;
final path = Path()
..moveTo(s.width / 2, 0)
..lineTo(s.width, s.height)
..lineTo(0, s.height)
..close();
canvas.drawPath(path, p);
}
@override
bool shouldRepaint(_) => false;
}
class _PlayButton extends StatelessWidget {
final bool playing;
final VoidCallback? onTap;
final double size;
final bool mono;
const _PlayButton({required this.playing, this.onTap, this.size = 40, this.mono = false});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: PyramidTheme.accent,
shape: mono ? BoxShape.rectangle : BoxShape.circle,
boxShadow: [BoxShadow(color: PyramidTheme.accentGlow, blurRadius: 18, offset: const Offset(0, 4))],
),
child: Icon(playing ? Icons.pause : Icons.play_arrow, color: PyramidTheme.accentFg, size: size * 0.45),
),
);
}
}
class _IconBtn extends StatelessWidget {
final IconData icon;
final VoidCallback? onTap;
const _IconBtn({required this.icon, this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(PyramidTheme.rSm),
child: Container(
width: 32, height: 32,
alignment: Alignment.center,
child: Icon(icon, size: 18, color: PyramidTheme.fgMuted),
),
);
}
}
class _SpeedPill extends StatelessWidget {
final String label;
final bool active;
final bool mono;
final VoidCallback? onTap;
const _SpeedPill({required this.label, this.active = false, this.mono = false, this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: active ? PyramidTheme.accentSoft : PyramidTheme.bg2,
borderRadius: BorderRadius.circular(mono ? 0 : PyramidTheme.rSm),
border: Border.all(color: active ? PyramidTheme.accentSoft : Colors.transparent),
),
child: Text(label, style: TextStyle(
fontFamily: PyramidTheme.fontMono, fontSize: 11, fontWeight: FontWeight.w500,
color: active ? PyramidTheme.accent : PyramidTheme.fgMuted,
letterSpacing: 0.2,
)),
),
);
}
}
class _Scrubber extends StatelessWidget {
final double progress; // 0..1
final ValueChanged<double>? onSeek;
const _Scrubber({required this.progress, this.onSeek});
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (_, c) {
return GestureDetector(
onTapDown: (d) => onSeek?.call((d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)),
onPanUpdate: (d) => onSeek?.call(((d.localPosition.dx) / c.maxWidth).clamp(0.0, 1.0)),
child: SizedBox(
height: 14,
child: Stack(alignment: Alignment.centerLeft, children: [
Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.bg3, borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(
widthFactor: progress,
child: Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.accent, borderRadius: BorderRadius.circular(2))),
),
]),
),
);
});
}
}
class _MiniBar extends StatelessWidget {
final double value;
const _MiniBar({required this.value});
@override
Widget build(BuildContext context) {
return Stack(alignment: Alignment.centerLeft, children: [
Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.bg3, borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(
widthFactor: value,
child: Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.fgMuted, borderRadius: BorderRadius.circular(2))),
),
]);
}
}
// ───────────────────────── Wave painters ─────────────────────────
class _ChunkyBarsPainter extends CustomPainter {
final double progress;
static const int count = 56;
static final List<double> _heights = _seed();
static List<double> _seed() {
final r = math.Random(7);
return List.generate(count, (i) {
final env = math.sin((i / count) * math.pi) * 0.6 + 0.4;
return 0.2 + r.nextDouble() * 0.8 * env;
});
}
_ChunkyBarsPainter({required this.progress});
@override
void paint(Canvas canvas, Size s) {
final gap = 2.0;
final barW = (s.width - gap * (count - 1)) / count;
final cursor = (progress * count).floor();
for (int i = 0; i < count; i++) {
final h = _heights[i] * s.height;
final x = i * (barW + gap);
final y = (s.height - h) / 2;
final played = i < cursor;
final isCursor = i == cursor;
final paint = Paint()
..color = isCursor
? PyramidTheme.fg
: played
? PyramidTheme.accent
: PyramidTheme.bg3;
final r = RRect.fromRectAndRadius(Rect.fromLTWH(x, y, barW, h), const Radius.circular(1));
canvas.drawRRect(r, paint);
}
}
@override
bool shouldRepaint(_ChunkyBarsPainter old) => old.progress != progress;
}
class _GracileWavePainter extends CustomPainter {
final double progress;
static const int count = 72;
static final List<double> _heights = _seed();
static List<double> _seed() {
final r = math.Random(11);
return List.generate(count, (i) {
final env = math.sin((i / count) * math.pi) * 0.55 + 0.45;
return 0.18 + r.nextDouble() * 0.82 * env;
});
}
_GracileWavePainter({required this.progress});
@override
void paint(Canvas canvas, Size s) {
final cy = s.height / 2;
// Baseline
canvas.drawLine(
Offset(0, cy), Offset(s.width, cy),
Paint()..color = PyramidTheme.border..strokeWidth = 0.6,
);
final step = s.width / (count - 1);
for (int i = 0; i < count; i++) {
final x = i * step;
final h = _heights[i] * s.height * 0.88;
final played = (x / s.width) <= progress;
final p = Paint()
..color = (played ? PyramidTheme.accent : PyramidTheme.fgDim).withOpacity(played ? 0.95 : 0.42)
..strokeWidth = 1.0
..strokeCap = StrokeCap.round;
canvas.drawLine(Offset(x, cy - h / 2), Offset(x, cy + h / 2), p);
}
// Playhead
final cx = progress * s.width;
canvas.drawLine(Offset(cx, 4), Offset(cx, s.height - 4),
Paint()..color = PyramidTheme.fg..strokeWidth = 1.2);
canvas.drawCircle(Offset(cx, cy), 2.4, Paint()..color = PyramidTheme.accent);
}
@override
bool shouldRepaint(_GracileWavePainter old) => old.progress != progress;
}
@@ -0,0 +1,76 @@
// pyramid_theme.dart
// Pyramid · Design Tokens für Flutter
//
// Zentrale Farb-, Typo- und Radius-Konstanten. Alle Player ziehen sich hier raus.
// Dark-first; Light-Modus umschaltbar via PyramidTheme.dark = false.
import 'package:flutter/material.dart';
class PyramidTheme {
PyramidTheme._();
/// Dark mode toggle. Beim Umschalten alle Player rebuilden.
static bool dark = true;
// ─── Accent (HSL → Color) ─────────────────────────────────────────────
// Default: Amber. Per setAccent(h,s,l) änderbar.
static double accentH = 42;
static double accentS = 95;
static double accentL = 58;
static Color get accent => HSLColor.fromAHSL(1, accentH, accentS / 100, accentL / 100).toColor();
static Color get accentSoft => HSLColor.fromAHSL(0.14, accentH, accentS / 100, accentL / 100).toColor();
static Color get accentGlow => HSLColor.fromAHSL(0.35, accentH, accentS / 100, accentL / 100).toColor();
static const Color accentFg = Color(0xFF0B0B0D);
static void setAccent(double h, double s, double l) {
accentH = h; accentS = s; accentL = l;
}
// ─── Backgrounds & Foregrounds ────────────────────────────────────────
static Color get bg0 => dark ? const Color(0xFF0A0A0C) : const Color(0xFFF5F4F0);
static Color get bg1 => dark ? const Color(0xFF111114) : const Color(0xFFFFFFFF);
static Color get bg2 => dark ? const Color(0xFF17171C) : const Color(0xFFFAF9F5);
static Color get bg3 => dark ? const Color(0xFF1E1E25) : const Color(0xFFF0EEEA);
static Color get bgHover => dark ? const Color(0xFF232330) : const Color(0xFFE9E7E1);
static Color get bgActive => dark ? const Color(0xFF2A2A38) : const Color(0xFFDFDCD3);
static Color get border => dark ? const Color(0xFF25252E) : const Color(0xFFE5E2DC);
static Color get borderStrong => dark ? const Color(0xFF32323D) : const Color(0xFFD3CFC6);
static Color get fg => dark ? const Color(0xFFECECF0) : const Color(0xFF1A1A1F);
static Color get fgMuted => dark ? const Color(0xFFA4A4B0) : const Color(0xFF54545E);
static Color get fgDim => dark ? const Color(0xFF6F6F7D) : const Color(0xFF8A8A92);
// ─── Radii ────────────────────────────────────────────────────────────
static const double rBase = 12;
static const double rSm = 7.2; // base * 0.6
static const double rLg = 18; // base * 1.5
static const double rXl = 24; // base * 2
static const double rPill = 999;
// ─── Typography ───────────────────────────────────────────────────────
// Erfordert: Geist + Geist Mono in pubspec.yaml als fonts.
// (Alternativ: durch ein anderes Sans/Mono-Paar ersetzen.)
static const String fontSans = 'Geist';
static const String fontMono = 'GeistMono';
static TextStyle get title => TextStyle(
fontFamily: fontSans, fontSize: 14, fontWeight: FontWeight.w500, color: fg, letterSpacing: -0.1,
);
static TextStyle get subtitle => TextStyle(
fontFamily: fontSans, fontSize: 12, color: fgMuted,
);
static TextStyle get mono => TextStyle(
fontFamily: fontMono, fontSize: 11, color: fgDim, letterSpacing: 0.4,
);
static TextStyle get monoStrong => TextStyle(
fontFamily: fontMono, fontSize: 11, color: fg, fontWeight: FontWeight.w500, letterSpacing: 0.4,
);
// ─── Shadows ──────────────────────────────────────────────────────────
static List<BoxShadow> get shadowLg => [
BoxShadow(color: Colors.black.withOpacity(dark ? 0.6 : 0.12), blurRadius: 40, offset: const Offset(0, 12)),
BoxShadow(color: Colors.black.withOpacity(dark ? 0.4 : 0.06), blurRadius: 8, offset: const Offset(0, 2)),
];
}
@@ -0,0 +1,544 @@
// pyramid_video_player.dart
// Pyramid · Video Player
//
// Drei Varianten:
// VideoPlayerVariant.defaultHover → Chrome erscheint nur bei Hover/Tap (für Vollbild-Embeds)
// VideoPlayerVariant.minimal → Chrome immer sichtbar (für kleine Inline-Embeds)
// VideoPlayerVariant.framed → Editorial: Stage in Card, Controls außerhalb (für Galerie-Karten)
//
// Hinweis: Dies ist ein UI-Mockup. Für echte Wiedergabe diesen Widget mit
// `video_player`-Package verbinden (controller.value.position etc. an `position` mappen).
//
// Verwendung:
// PyramidVideoPlayer(
// variant: VideoPlayerVariant.defaultHover,
// title: 'test clip',
// subtitle: 'pyramid · 1080p',
// duration: Duration(minutes: 4, seconds: 12),
// position: Duration(minutes: 1, seconds: 28),
// buffered: 0.65,
// playing: true,
// onTogglePlay: () { ... },
// onSeek: (Duration to) { ... },
// onSpeedChange: (double speed) { ... },
// onQualityChange: (String quality) { ... },
// );
import 'package:flutter/material.dart';
import 'pyramid_theme.dart';
enum VideoPlayerVariant { defaultHover, minimal, framed }
class PyramidVideoPlayer extends StatefulWidget {
final VideoPlayerVariant variant;
final String title;
final String subtitle;
final Duration duration;
final Duration position;
final double buffered; // 0..1
final bool playing;
final double volume; // 0..1
final VoidCallback? onTogglePlay;
final ValueChanged<Duration>? onSeek;
final ValueChanged<double>? onVolumeChange;
final ValueChanged<double>? onSpeedChange;
final ValueChanged<String>? onQualityChange;
final VoidCallback? onSkipBack;
final VoidCallback? onSkipForward;
final VoidCallback? onFullscreen;
final VoidCallback? onCaptions;
final VoidCallback? onMore;
const PyramidVideoPlayer({
super.key,
this.variant = VideoPlayerVariant.defaultHover,
required this.title,
this.subtitle = '',
required this.duration,
required this.position,
this.buffered = 0.0,
this.playing = false,
this.volume = 0.7,
this.onTogglePlay,
this.onSeek,
this.onVolumeChange,
this.onSpeedChange,
this.onQualityChange,
this.onSkipBack,
this.onSkipForward,
this.onFullscreen,
this.onCaptions,
this.onMore,
});
@override
State<PyramidVideoPlayer> createState() => _PyramidVideoPlayerState();
}
class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
bool _hovered = false;
String _quality = 'Auto';
double _speed = 1.0;
bool _settingsOpen = false;
static const _qualities = ['Auto', '1080p', '720p', '480p', '360p'];
static const _speeds = [0.5, 1.0, 1.25, 1.5, 2.0];
double get progress => widget.duration.inMilliseconds == 0
? 0
: widget.position.inMilliseconds / widget.duration.inMilliseconds;
bool get _showChrome {
if (widget.variant == VideoPlayerVariant.minimal) return true;
if (widget.variant == VideoPlayerVariant.framed) return true;
return _hovered || !widget.playing;
}
String _fmt(Duration d) {
final m = d.inMinutes;
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
if (widget.variant == VideoPlayerVariant.framed) return _buildFramed();
return _buildOverlay();
}
// ─── Default / Minimal ─────────────────────────────────────────────
Widget _buildOverlay() {
final aspect = widget.variant == VideoPlayerVariant.minimal ? 16 / 9 : 16 / 10;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() { _hovered = false; _settingsOpen = false; }),
child: GestureDetector(
onTap: widget.onTogglePlay,
child: ClipRRect(
borderRadius: BorderRadius.circular(PyramidTheme.rLg),
child: AspectRatio(
aspectRatio: aspect,
child: Stack(fit: StackFit.expand, children: [
_stage(),
_grain(),
if (!widget.playing) Center(child: _CenterPlay(onTap: widget.onTogglePlay)),
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showChrome ? 1 : 0,
child: _topBar(onDark: true),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showChrome ? 1 : 0,
child: Align(alignment: Alignment.bottomCenter, child: _bottomBar(onDark: true)),
),
]),
),
),
),
);
}
// ─── Framed ────────────────────────────────────────────────────────
Widget _buildFramed() {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: PyramidTheme.bg1,
border: Border.all(color: PyramidTheme.border),
borderRadius: BorderRadius.circular(PyramidTheme.rLg),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
ClipRRect(
borderRadius: BorderRadius.circular(PyramidTheme.rLg - 4),
child: AspectRatio(
aspectRatio: 16 / 9,
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTogglePlay,
child: Stack(fit: StackFit.expand, children: [
_stage(),
_grain(),
if (!widget.playing) Center(child: _CenterPlay(onTap: widget.onTogglePlay)),
_topBar(onDark: true),
]),
),
),
),
),
const SizedBox(height: 12),
_bottomBar(onDark: false),
]),
);
}
// ─── Stage (placeholder bg) ────────────────────────────────────────
Widget _stage() {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft, end: Alignment.bottomRight,
colors: [const Color(0xFF1A1A22), const Color(0xFF0C0C10)],
),
),
alignment: Alignment.center,
child: Opacity(
opacity: 0.35,
child: CustomPaint(size: const Size(72, 60), painter: _BigTriPainter()),
),
);
}
Widget _grain() => IgnorePointer(child: Opacity(
opacity: 0.05,
child: Container(decoration: const BoxDecoration(color: Colors.white12)),
));
// ─── Top bar (title + captions + more) ─────────────────────────────
Widget _topBar({required bool onDark}) {
final fg = onDark ? Colors.white : PyramidTheme.fg;
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
gradient: onDark
? LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black.withOpacity(0.5), Colors.transparent])
: null,
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(widget.title, style: TextStyle(color: fg, fontSize: 13, fontFamily: PyramidTheme.fontSans, fontWeight: FontWeight.w500)),
if (widget.subtitle.isNotEmpty)
Text(widget.subtitle.toUpperCase(), style: TextStyle(color: fg.withOpacity(0.65), fontSize: 10, fontFamily: PyramidTheme.fontMono, letterSpacing: 1.2)),
])),
_OverlayBtn(icon: Icons.closed_caption_outlined, onTap: widget.onCaptions, onDark: onDark),
_OverlayBtn(icon: Icons.more_horiz, onTap: widget.onMore, onDark: onDark),
]),
);
}
// ─── Bottom bar (scrubber + controls) ──────────────────────────────
Widget _bottomBar({required bool onDark}) {
final iconColor = onDark ? Colors.white.withOpacity(0.85) : PyramidTheme.fgMuted;
final timeColor = onDark ? Colors.white.withOpacity(0.78) : PyramidTheme.fgDim;
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
gradient: onDark
? LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black.withOpacity(0.75), Colors.transparent])
: null,
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
_scrubber(onDark: onDark),
const SizedBox(height: 10),
Row(children: [
// Left side
_PlayMini(playing: widget.playing, onTap: widget.onTogglePlay, onDark: onDark),
const SizedBox(width: 4),
_OverlayBtn(icon: Icons.replay_10, onTap: widget.onSkipBack, onDark: onDark),
_OverlayBtn(icon: Icons.forward_10, onTap: widget.onSkipForward, onDark: onDark),
_OverlayBtn(icon: Icons.volume_up_outlined, onTap: null, onDark: onDark),
const SizedBox(width: 8),
Text('${_fmt(widget.position)} / ${_fmt(widget.duration)}',
style: TextStyle(color: timeColor, fontSize: 11, fontFamily: PyramidTheme.fontMono, letterSpacing: 0.4)),
const Spacer(),
// Right side
_QualityBadge(label: _quality == 'Auto' ? '1080p' : _quality, onDark: onDark),
const SizedBox(width: 4),
_SettingsButton(
open: _settingsOpen,
onTap: () => setState(() => _settingsOpen = !_settingsOpen),
speed: '${_speed == _speed.toInt() ? _speed.toInt() : _speed}x',
quality: _quality,
onDark: onDark,
onSpeed: (s) {
setState(() { _speed = s; _settingsOpen = false; });
widget.onSpeedChange?.call(s);
},
onQuality: (q) {
setState(() { _quality = q; _settingsOpen = false; });
widget.onQualityChange?.call(q);
},
speeds: _speeds, qualities: _qualities,
),
_OverlayBtn(icon: Icons.fullscreen, onTap: widget.onFullscreen, onDark: onDark),
]),
]),
);
}
Widget _scrubber({required bool onDark}) {
final trackBg = onDark ? Colors.white.withOpacity(0.18) : PyramidTheme.bg3;
final bufferedBg = onDark ? Colors.white.withOpacity(0.28) : PyramidTheme.borderStrong;
return LayoutBuilder(builder: (_, c) => GestureDetector(
onTapDown: (d) => widget.onSeek?.call(Duration(
milliseconds: (widget.duration.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round(),
)),
onPanUpdate: (d) => widget.onSeek?.call(Duration(
milliseconds: (widget.duration.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round(),
)),
child: SizedBox(
height: 14,
child: Stack(alignment: Alignment.centerLeft, children: [
Container(height: 3, decoration: BoxDecoration(color: trackBg, borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(widthFactor: widget.buffered, child: Container(height: 3, decoration: BoxDecoration(color: bufferedBg, borderRadius: BorderRadius.circular(2)))),
FractionallySizedBox(widthFactor: progress, child: Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.accent, borderRadius: BorderRadius.circular(2)))),
Positioned(
left: c.maxWidth * progress - 5,
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: PyramidTheme.accent, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.35), blurRadius: 0, spreadRadius: 3)])),
),
]),
),
));
}
}
// ───────────────────────── Overlay sub-widgets ─────────────────────────
class _CenterPlay extends StatelessWidget {
final VoidCallback? onTap;
const _CenterPlay({this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 64, height: 64,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.08),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.15)),
),
child: const Icon(Icons.play_arrow, color: Colors.white, size: 28),
),
);
}
}
class _OverlayBtn extends StatelessWidget {
final IconData icon;
final VoidCallback? onTap;
final bool onDark;
const _OverlayBtn({required this.icon, this.onTap, required this.onDark});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(PyramidTheme.rSm),
child: Container(
width: 32, height: 32,
alignment: Alignment.center,
child: Icon(icon, size: 17, color: onDark ? Colors.white.withOpacity(0.85) : PyramidTheme.fgMuted),
),
);
}
}
class _PlayMini extends StatelessWidget {
final bool playing;
final VoidCallback? onTap;
final bool onDark;
const _PlayMini({required this.playing, this.onTap, required this.onDark});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: onDark ? Colors.white : PyramidTheme.accent,
shape: BoxShape.circle,
),
child: Icon(playing ? Icons.pause : Icons.play_arrow,
color: onDark ? const Color(0xFF0A0A0C) : PyramidTheme.accentFg, size: 16),
),
);
}
}
class _QualityBadge extends StatelessWidget {
final String label;
final bool onDark;
const _QualityBadge({required this.label, required this.onDark});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: onDark ? Colors.white.withOpacity(0.08) : PyramidTheme.bg2,
borderRadius: BorderRadius.circular(PyramidTheme.rSm),
),
child: Text(label.toUpperCase(),
style: TextStyle(
fontFamily: PyramidTheme.fontMono, fontSize: 10, fontWeight: FontWeight.w500, letterSpacing: 0.6,
color: onDark ? Colors.white.withOpacity(0.78) : PyramidTheme.fgMuted,
)),
);
}
}
// ───────────────────────── Settings menu ─────────────────────────
class _SettingsButton extends StatelessWidget {
final bool open;
final VoidCallback onTap;
final String speed;
final String quality;
final ValueChanged<double> onSpeed;
final ValueChanged<String> onQuality;
final List<double> speeds;
final List<String> qualities;
final bool onDark;
const _SettingsButton({
required this.open, required this.onTap,
required this.speed, required this.quality,
required this.onSpeed, required this.onQuality,
required this.speeds, required this.qualities,
required this.onDark,
});
@override
Widget build(BuildContext context) {
return PopupMenuButton<void>(
tooltip: 'Einstellungen',
offset: const Offset(0, -12),
position: PopupMenuPosition.over,
color: onDark ? const Color(0xFF14141A).withOpacity(0.96) : PyramidTheme.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PyramidTheme.rBase),
side: BorderSide(color: onDark ? Colors.white.withOpacity(0.1) : PyramidTheme.border),
),
itemBuilder: (ctx) => [
PopupMenuItem<void>(
enabled: false,
padding: EdgeInsets.zero,
child: _SettingsMenu(
speed: speed, quality: quality,
onSpeed: onSpeed, onQuality: onQuality,
speeds: speeds, qualities: qualities,
onDark: onDark,
),
),
],
child: Container(
width: 32, height: 32,
alignment: Alignment.center,
child: Icon(Icons.settings_outlined, size: 17, color: onDark ? Colors.white.withOpacity(0.85) : PyramidTheme.fgMuted),
),
);
}
}
class _SettingsMenu extends StatefulWidget {
final String speed;
final String quality;
final ValueChanged<double> onSpeed;
final ValueChanged<String> onQuality;
final List<double> speeds;
final List<String> qualities;
final bool onDark;
const _SettingsMenu({
required this.speed, required this.quality,
required this.onSpeed, required this.onQuality,
required this.speeds, required this.qualities,
required this.onDark,
});
@override
State<_SettingsMenu> createState() => _SettingsMenuState();
}
class _SettingsMenuState extends State<_SettingsMenu> {
String? section; // null | 'speed' | 'quality'
@override
Widget build(BuildContext context) {
final fg = widget.onDark ? Colors.white.withOpacity(0.9) : PyramidTheme.fg;
final dim = widget.onDark ? Colors.white.withOpacity(0.6) : PyramidTheme.fgMuted;
Widget row(String label, String value, VoidCallback onTap) => InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(label, style: TextStyle(color: fg, fontSize: 12, fontFamily: PyramidTheme.fontSans)),
const SizedBox(width: 24),
Text(value, style: TextStyle(color: dim, fontSize: 11, fontFamily: PyramidTheme.fontMono)),
const SizedBox(width: 4),
Icon(Icons.chevron_right, size: 14, color: dim),
]),
),
);
Widget item(String label, bool active, VoidCallback onTap) => InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
SizedBox(width: 18, child: active ? Icon(Icons.check, size: 14, color: PyramidTheme.accent) : null),
Text(label, style: TextStyle(
color: active ? PyramidTheme.accent : fg,
fontSize: 12,
fontFamily: section == 'speed' ? PyramidTheme.fontMono : PyramidTheme.fontSans,
)),
]),
),
);
if (section == null) {
return SizedBox(
width: 200,
child: Column(mainAxisSize: MainAxisSize.min, children: [
row('Geschwindigkeit', widget.speed, () => setState(() => section = 'speed')),
row('Qualität', widget.quality, () => setState(() => section = 'quality')),
]),
);
}
final isSpeed = section == 'speed';
return SizedBox(
width: 180,
child: Column(mainAxisSize: MainAxisSize.min, children: [
InkWell(
onTap: () => setState(() => section = null),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.arrow_back, size: 12, color: dim),
const SizedBox(width: 8),
Text(isSpeed ? 'GESCHWINDIGKEIT' : 'QUALITÄT',
style: TextStyle(color: dim, fontSize: 10, fontFamily: PyramidTheme.fontMono, letterSpacing: 0.8)),
]),
),
),
Container(height: 1, color: widget.onDark ? Colors.white.withOpacity(0.08) : PyramidTheme.border),
if (isSpeed)
...widget.speeds.map((s) {
final lbl = '${s == s.toInt() ? s.toInt() : s}x';
return item(lbl, lbl == widget.speed, () => widget.onSpeed(s));
})
else
...widget.qualities.map((q) => item(q, q == widget.quality, () => widget.onQuality(q))),
]),
);
}
}
class _BigTriPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size s) {
final p = Paint()..color = PyramidTheme.accent;
final path = Path()
..moveTo(s.width / 2, 0)
..lineTo(s.width, s.height)
..lineTo(0, s.height)
..close();
canvas.drawPath(path, p);
}
@override
bool shouldRepaint(_) => false;
}
+32
View File
@@ -0,0 +1,32 @@
name: pyramid_players
description: Pyramid Audio + Video Player widgets (Mockup-Skelett, ohne echte Wiedergabe).
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.0.0 <4.0.0'
flutter: '>=3.16.0'
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
# Optional: Geist + Geist Mono einbinden, sonst Fallback auf System-Sans/Mono
# fonts:
# - family: Geist
# fonts:
# - asset: fonts/Geist-Regular.ttf
# - asset: fonts/Geist-Medium.ttf
# weight: 500
# - asset: fonts/Geist-SemiBold.ttf
# weight: 600
# - family: GeistMono
# fonts:
# - asset: fonts/GeistMono-Regular.ttf
# - asset: fonts/GeistMono-Medium.ttf
# weight: 500
+110
View File
@@ -0,0 +1,110 @@
/* Inline SVG icons for Pyramid — React components */
const Icon = ({ name, size = 16, stroke = 1.75, ...rest }) => {
const s = size;
const sw = stroke;
const paths = {
hash: <><line x1="4" y1="9" x2="20" y2="9" /><line x1="4" y1="15" x2="20" y2="15" /><line x1="10" y1="3" x2="8" y2="21" /><line x1="16" y1="3" x2="14" y2="21" /></>,
voice: <><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 10v2a7 7 0 0 1-14 0v-2" /><line x1="12" y1="19" x2="12" y2="23" /></>,
announce: <><path d="M3 11l18-8v18l-18-8v-2z" /><path d="M11 19v3" /></>,
lock: <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>,
search: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></>,
plus: <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>,
close: <><line x1="6" y1="6" x2="18" y2="18" /><line x1="18" y1="6" x2="6" y2="18" /></>,
chevronDown: <polyline points="6 9 12 15 18 9" />,
chevronRight: <polyline points="9 6 15 12 9 18" />,
dots: <><circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" /></>,
dotsV: <><circle cx="12" cy="5" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="12" cy="19" r="1.6" /></>,
smile: <><circle cx="12" cy="12" r="9" /><line x1="9" y1="10" x2="9.01" y2="10" /><line x1="15" y1="10" x2="15.01" y2="10" /><path d="M8 15c1 1.5 2.5 2.5 4 2.5s3-1 4-2.5" /></>,
reply: <><polyline points="9 14 4 9 9 4" /><path d="M20 20v-7a4 4 0 0 0-4-4H4" /></>,
send: <><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></>,
pin: <><line x1="12" y1="17" x2="12" y2="22" /><path d="M9 3h6l-1 6 3 3v2H7v-2l3-3-1-6z" /></>,
users: <><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></>,
bell: <><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.73 21a2 2 0 0 1-3.46 0" /></>,
inbox: <><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></>,
mic: <><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M5 11a7 7 0 0 0 14 0" /><line x1="12" y1="18" x2="12" y2="22" /></>,
micOff: <><line x1="1" y1="1" x2="23" y2="23" /><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V5a3 3 0 0 0-5.94-.6" /><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" /></>,
headphones: <><path d="M3 18v-6a9 9 0 0 1 18 0v6" /><path d="M21 19a2 2 0 0 1-2 2h-1v-6h3v4zM3 19a2 2 0 0 0 2 2h1v-6H3v4z" /></>,
videoOff: <><path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10" /><line x1="1" y1="1" x2="23" y2="23" /></>,
video: <><polygon points="23 7 16 12 23 17 23 7" /><rect x="1" y="5" width="15" height="14" rx="2" /></>,
screen: <><rect x="2" y="3" width="20" height="14" rx="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /></>,
phoneOff: <><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92V20a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3.08a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 10" /><line x1="23" y1="1" x2="1" y2="23" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></>,
edit: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></>,
trash: <><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2" /></>,
copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>,
bookmark: <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />,
link: <><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></>,
gif: <><rect x="2" y="5" width="20" height="14" rx="2" /><path d="M8 10h-2v4h2" /><line x1="11" y1="10" x2="11" y2="14" /><path d="M18 10h-3v4" /><line x1="15" y1="12" x2="17" y2="12" /></>,
paperclip: <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />,
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" /></>,
moon: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />,
sidebarLeft: <><rect x="3" y="4" width="18" height="16" rx="2" /><line x1="9" y1="4" x2="9" y2="20" /></>,
check: <polyline points="20 6 9 17 4 12" />,
arrowUp: <><line x1="12" y1="19" x2="12" y2="5" /><polyline points="5 12 12 5 19 12" /></>,
sparkle: <path d="M12 2l2.2 6.4L21 10l-6.8 2.2L12 18l-2.2-5.8L3 10l6.8-1.6L12 2z" />,
palette: <><circle cx="12" cy="12" r="9" /><circle cx="7.5" cy="10.5" r="1" /><circle cx="12" cy="7.5" r="1" /><circle cx="16.5" cy="10.5" r="1" /><path d="M12 21c-1.7 0-3-1.3-3-3 0-1 .5-1.5 1-2 .5-.5 1-1 1-2 0-1.1-.9-2-2-2" /></>,
globe: <><circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /><path d="M12 2a15 15 0 0 1 0 20a15 15 0 0 1 0-20z" /></>,
at: <><circle cx="12" cy="12" r="4" /><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" /></>,
filter: <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />,
};
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" {...rest}>
{paths[name]}
</svg>
);
};
/* Pyramid logo/mark */
const PyramidMark = ({ size = 36, accent }) => {
const color = accent || 'var(--accent)';
return (
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
<defs>
<linearGradient id={`pyr-face-${size}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stopColor={color} stopOpacity="1" />
<stop offset="1" stopColor={color} stopOpacity="0.55" />
</linearGradient>
<linearGradient id={`pyr-side-${size}`} x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stopColor={color} stopOpacity="0.85" />
<stop offset="1" stopColor={color} stopOpacity="0.25" />
</linearGradient>
</defs>
{/* right face */}
<path d="M24 6 L42 40 L24 34 Z" fill={`url(#pyr-side-${size})`} />
{/* left face (front) */}
<path d="M24 6 L6 40 L24 34 Z" fill={`url(#pyr-face-${size})`} />
{/* base outline */}
<path d="M6 40 L24 34 L42 40" stroke={color} strokeOpacity="0.4" strokeWidth="1" fill="none" strokeLinejoin="round" />
{/* edge highlight */}
<path d="M24 6 L24 34" stroke={color} strokeOpacity="0.9" strokeWidth="0.8" />
</svg>
);
};
/* Small pyramid for space icons */
const PyramidGlyph = ({ size = 26, color = 'currentColor', short }) => (
<div style={{position: 'relative', width: size, height: size, display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" style={{position: 'absolute', inset: 0}}>
<path d="M16 3 L29 27 L3 27 Z" fill={color} opacity="0.22" />
<path d="M16 3 L29 27 L3 27 Z" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
<path d="M16 3 L16 27" stroke={color} strokeWidth="1" opacity="0.6" />
</svg>
{short && (
<span style={{
position: 'relative',
zIndex: 1,
fontSize: size * 0.34,
fontWeight: 700,
color: color,
marginTop: size * 0.12,
letterSpacing: '-0.02em',
}}>{short}</span>
)}
</div>
);
window.Icon = Icon;
window.PyramidMark = PyramidMark;
window.PyramidGlyph = PyramidGlyph;
+976
View File
@@ -0,0 +1,976 @@
/* ============================================
PYRAMID · Modals, popovers, overlays
============================================ */
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fade-in 0.2s var(--spring-soft);
}
[data-theme="light"] .overlay { background: rgba(40,35,25,0.35); }
.modal {
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: var(--r-xl);
box-shadow: var(--shadow-pop);
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
animation: bounce-in 0.35s var(--spring-bounce);
}
.modal-close {
position: absolute;
top: 16px; right: 16px;
width: 28px; height: 28px;
border-radius: 50%;
background: var(--bg-2);
color: var(--fg-muted);
display: flex; align-items: center; justify-content: center;
transition: all 0.2s var(--spring-bounce);
z-index: 2;
}
.modal-close:hover {
background: var(--bg-hover);
color: var(--fg);
transform: rotate(calc(90deg * var(--motion)));
}
/* ==== Settings modal ==== */
.settings-modal {
width: 920px; height: 640px;
display: grid;
grid-template-columns: 220px 1fr;
}
.settings-nav {
background: var(--bg-2);
padding: 18px 12px;
overflow-y: auto;
border-right: 1px solid var(--border);
}
.settings-nav-section {
padding: 10px 10px 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--fg-dim);
}
.settings-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--r-sm);
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--fg-muted);
transition: all 0.15s;
}
.settings-nav-item:hover { background: var(--bg-hover); color: var(--fg); }
.settings-nav-item.active { background: var(--accent-soft); color: var(--accent); }
.settings-content {
padding: 32px 40px;
overflow-y: auto;
position: relative;
}
.settings-content h2 {
font-size: 22px;
font-weight: 600;
margin: 0 0 6px;
}
.settings-content .settings-subtitle {
color: var(--fg-muted);
font-size: 13px;
margin-bottom: 24px;
}
.settings-group {
margin-bottom: 28px;
}
.settings-group-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--fg-dim);
margin-bottom: 10px;
}
.settings-row {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.settings-row:last-child { border-bottom: none; }
.settings-row-label { flex: 1; }
.settings-row-title { font-size: 14px; font-weight: 500; color: var(--fg); }
.settings-row-desc { font-size: 12px; color: var(--fg-muted); margin-top: 2px; }
/* Toggle */
.toggle {
width: 38px; height: 22px;
background: var(--bg-3);
border-radius: 999px;
position: relative;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle::after {
content: '';
position: absolute;
top: 2px; left: 2px;
width: 18px; height: 18px;
background: white;
border-radius: 50%;
transition: transform 0.25s var(--spring-bounce);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.toggle.on { background: var(--accent); }
.toggle.on::after { transform: translateX(16px); }
/* Select */
.settings-select {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 6px 10px;
color: var(--fg);
font-size: 13px;
cursor: pointer;
outline: none;
transition: border-color 0.2s;
}
.settings-select:hover { border-color: var(--border-strong); }
.settings-select:focus { border-color: var(--accent); }
/* Account header in settings */
.account-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: linear-gradient(135deg, var(--accent-soft), transparent);
border: 1px solid var(--border);
border-radius: var(--r-lg);
margin-bottom: 28px;
}
.account-card .avatar {
width: 60px; height: 60px;
border-radius: var(--r-lg);
background: linear-gradient(135deg, #ff6b9d, #c471f5);
font-size: 22px;
position: relative;
}
.account-handle {
color: var(--fg-muted);
font-size: 13px;
font-family: var(--font-mono);
}
.account-name { font-size: 18px; font-weight: 600; }
/* ==== Emoji picker ==== */
.emoji-picker {
position: absolute;
width: 360px; height: 420px;
background: var(--bg-0);
border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-pop);
z-index: 900;
display: flex;
flex-direction: column;
overflow: hidden;
animation: pop-in 0.2s var(--spring-bounce);
transform-origin: bottom right;
}
.emoji-picker-search {
padding: 10px;
border-bottom: 1px solid var(--border);
}
.emoji-picker-search input {
width: 100%;
padding: 7px 10px;
background: var(--bg-2);
border: 1px solid transparent;
border-radius: var(--r-sm);
color: var(--fg);
font-size: 13px;
outline: none;
}
.emoji-picker-search input:focus { border-color: var(--accent); background: var(--bg-1); }
.emoji-picker-tabs {
display: flex;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
}
.emoji-picker-tabs::-webkit-scrollbar { display: none; }
.emoji-tab {
flex-shrink: 0;
padding: 6px 8px;
border-radius: var(--r-sm);
font-size: 16px;
cursor: pointer;
opacity: 0.55;
transition: all 0.2s var(--spring-bounce);
}
.emoji-tab:hover { opacity: 1; background: var(--bg-hover); }
.emoji-tab.active { opacity: 1; background: var(--accent-soft); }
.emoji-grid-wrap {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.emoji-category-title {
padding: 8px 4px 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--fg-dim);
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 2px;
}
.emoji-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s var(--spring-bounce);
}
.emoji-cell:hover {
background: var(--bg-hover);
transform: scale(calc(1 + 0.25 * var(--motion)));
}
.emoji-picker-preview {
padding: 8px 12px;
border-top: 1px solid var(--border);
background: var(--bg-2);
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
}
.emoji-picker-preview .big { font-size: 22px; }
.emoji-picker-preview .name {
font-size: 12px;
font-weight: 600;
color: var(--fg);
}
.emoji-picker-preview .shortcode {
font-size: 11px;
font-family: var(--font-mono);
color: var(--fg-dim);
}
/* ==== Context menu ==== */
.context-menu {
position: absolute;
background: var(--bg-0);
border: 1px solid var(--border);
border-radius: var(--r-base);
box-shadow: var(--shadow-pop);
padding: 4px;
min-width: 200px;
z-index: 950;
animation: pop-in 0.16s var(--spring-bounce);
transform-origin: top left;
}
.context-item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: var(--r-sm);
cursor: pointer;
font-size: 13px;
color: var(--fg);
transition: background 0.1s;
}
.context-item:hover { background: var(--bg-hover); }
.context-item.danger { color: var(--danger); }
.context-item.danger:hover { background: rgba(229, 72, 77, 0.12); }
.context-item .shortcut {
margin-left: auto;
color: var(--fg-dim);
font-size: 11px;
font-family: var(--font-mono);
}
.context-divider {
height: 1px;
background: var(--border);
margin: 4px 2px;
}
/* ==== Profile popover ==== */
.profile-popover {
position: absolute;
width: 320px;
background: var(--bg-0);
border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-pop);
z-index: 900;
overflow: hidden;
animation: pop-in 0.22s var(--spring-bounce);
transform-origin: top left;
}
.profile-banner {
height: 72px;
background: linear-gradient(135deg, var(--accent), hsl(calc(var(--accent-h) + 40) 80% 50%));
position: relative;
}
.profile-banner::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 30% 40%, rgba(255,255,255,0.2), transparent 60%);
}
.profile-content {
padding: 0 16px 16px;
margin-top: -32px;
position: relative;
}
.profile-avatar {
width: 70px; height: 70px;
border-radius: var(--r-lg);
border: 4px solid var(--bg-0);
background: linear-gradient(135deg, #ff6b9d, #c471f5);
display: flex; align-items: center; justify-content: center;
font-size: 26px;
font-weight: 600;
color: white;
position: relative;
margin-bottom: 10px;
}
.profile-name { font-size: 18px; font-weight: 600; }
.profile-handle {
font-size: 12px;
color: var(--fg-muted);
font-family: var(--font-mono);
margin-bottom: 12px;
}
.profile-section {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.profile-section-title {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fg-dim);
margin-bottom: 6px;
}
.profile-about {
font-size: 13px;
color: var(--fg-muted);
line-height: 1.5;
}
.profile-actions { display: flex; gap: 6px; margin-top: 14px; }
.profile-btn {
flex: 1;
padding: 7px 10px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 12px;
font-weight: 500;
color: var(--fg);
display: flex; align-items: center; justify-content: center; gap: 6px;
transition: all 0.2s var(--spring-bounce);
}
.profile-btn.primary {
background: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
}
.profile-btn:hover {
transform: translateY(calc(-1px * var(--motion)));
border-color: var(--accent);
}
.profile-roles { display: flex; flex-wrap: wrap; gap: 4px; }
.profile-role {
padding: 2px 8px;
border-radius: var(--r-pill);
background: var(--bg-3);
font-size: 11px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
}
.profile-role .dot {
width: 8px; height: 8px; border-radius: 50%;
}
/* ==== Voice channel ==== */
.voice-hero {
flex: 1;
display: flex;
flex-direction: column;
padding: 24px;
background:
radial-gradient(ellipse at top left, var(--accent-soft), transparent 50%),
radial-gradient(ellipse at bottom right, hsl(calc(var(--accent-h) + 60) 60% 40% / 0.15), transparent 50%),
var(--bg-1);
overflow-y: auto;
}
.voice-header-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.voice-title { font-size: 20px; font-weight: 600; }
.voice-live-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: var(--r-pill);
background: rgba(229,72,77,0.12);
color: var(--danger);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.voice-live-pill .live-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--danger);
animation: pulse-ring 1.4s infinite;
}
.voice-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 28px;
}
.voice-tile {
aspect-ratio: 16/10;
background: var(--bg-2);
border: 1.5px solid var(--border);
border-radius: var(--r-lg);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s var(--spring-bounce);
}
.voice-tile.speaking {
border-color: var(--online);
box-shadow: 0 0 0 3px hsl(142 76% 50% / 0.2);
}
.voice-tile-avatar {
width: 72px; height: 72px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 600;
color: white;
}
.voice-tile.speaking .voice-tile-avatar {
animation: pulse-voice 1.2s infinite;
}
@keyframes pulse-voice {
0%, 100% { box-shadow: 0 0 0 0 hsl(142 76% 50% / 0.6); }
50% { box-shadow: 0 0 0 12px hsl(142 76% 50% / 0); }
}
.voice-tile-name {
position: absolute;
bottom: 10px; left: 10px;
background: rgba(0,0,0,0.55);
color: white;
padding: 3px 8px;
border-radius: var(--r-sm);
font-size: 12px;
font-weight: 500;
backdrop-filter: blur(4px);
display: flex;
align-items: center;
gap: 6px;
}
.voice-wave {
display: inline-flex;
align-items: center;
gap: 2px;
height: 10px;
}
.voice-wave span {
width: 2px;
background: var(--online);
border-radius: 1px;
animation: voice-wave 0.8s infinite ease-in-out;
}
.voice-wave span:nth-child(1) { animation-delay: 0s; height: 6px; }
.voice-wave span:nth-child(2) { animation-delay: 0.2s; height: 10px; }
.voice-wave span:nth-child(3) { animation-delay: 0.4s; height: 8px; }
.voice-wave span:nth-child(4) { animation-delay: 0.1s; height: 10px; }
.voice-status-icons {
position: absolute;
top: 10px; right: 10px;
display: flex;
gap: 4px;
}
.voice-status-icon {
width: 24px; height: 24px;
background: rgba(0,0,0,0.55);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
backdrop-filter: blur(4px);
}
.voice-status-icon.muted { background: var(--danger); }
.voice-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--r-xl);
margin-top: auto;
}
.voice-ctrl-btn {
width: 48px; height: 48px;
border-radius: 50%;
background: var(--bg-3);
color: var(--fg);
display: flex; align-items: center; justify-content: center;
transition: all 0.2s var(--spring-bounce);
}
.voice-ctrl-btn:hover { transform: scale(calc(1 + 0.08 * var(--motion))); background: var(--bg-hover); }
.voice-ctrl-btn.on { background: var(--accent); color: var(--accent-fg); }
.voice-ctrl-btn.danger { background: var(--danger); color: white; }
.voice-ctrl-btn.danger:hover { background: #d13f43; transform: scale(calc(1 + 0.08 * var(--motion))) rotate(calc(135deg * var(--motion))); }
/* ==== Search modal ==== */
.search-modal {
width: 640px;
max-height: 560px;
}
.search-input-row {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.search-input-row svg { color: var(--fg-dim); flex-shrink: 0; }
.search-input-row input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--fg);
font-size: 16px;
}
.search-input-row .esc {
padding: 2px 6px;
background: var(--bg-3);
border-radius: 4px;
font-size: 10px;
font-family: var(--font-mono);
color: var(--fg-muted);
}
.search-filters {
display: flex;
gap: 6px;
padding: 10px 20px;
border-bottom: 1px solid var(--border);
overflow-x: auto;
scrollbar-width: none;
}
.search-filters::-webkit-scrollbar { display: none; }
.search-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--r-pill);
background: var(--bg-2);
border: 1px solid var(--border);
font-size: 12px;
color: var(--fg-muted);
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s var(--spring-bounce);
white-space: nowrap;
}
.search-chip:hover { border-color: var(--accent); color: var(--fg); }
.search-chip.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
font-weight: 500;
}
.search-results {
overflow-y: auto;
padding: 6px;
max-height: 400px;
}
.search-result {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--r-base);
cursor: pointer;
transition: background 0.15s;
}
.search-result:hover,
.search-result.selected { background: var(--accent-soft); }
.search-result .avatar {
width: 32px; height: 32px;
border-radius: var(--r-sm);
font-size: 12px;
}
.search-result-content { flex: 1; min-width: 0; }
.search-result-top {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
}
.search-result-name { font-weight: 600; }
.search-result-meta { font-size: 11px; color: var(--fg-dim); }
.search-result-snippet {
font-size: 12px;
color: var(--fg-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-result-snippet mark {
background: var(--accent-soft);
color: var(--accent);
padding: 0 3px;
border-radius: 3px;
}
.search-footer {
padding: 10px 20px;
border-top: 1px solid var(--border);
display: flex;
gap: 16px;
font-size: 11px;
color: var(--fg-dim);
background: var(--bg-2);
}
.search-footer kbd {
display: inline-block;
padding: 1px 5px;
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: 3px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--fg-muted);
margin: 0 2px;
}
/* ==== Tweaks panel ==== */
.tweaks-panel {
position: fixed;
bottom: 20px; right: 20px;
width: 300px;
background: var(--bg-0);
border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-pop);
z-index: 800;
overflow: hidden;
animation: slide-up 0.3s var(--spring-bounce);
}
.tweaks-header {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
background: var(--bg-1);
}
.tweaks-title { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 6px; }
.tweaks-body { padding: 14px 16px; display: flex; flex-direction: column; gap: 14px; }
.tweak-row { display: flex; flex-direction: column; gap: 6px; }
.tweak-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--fg-dim);
display: flex;
justify-content: space-between;
}
.tweak-label .val { color: var(--fg); font-family: var(--font-mono); }
.tweak-row input[type=range] {
width: 100%;
accent-color: var(--accent);
}
.tweak-swatches { display: flex; gap: 6px; }
.tweak-swatch {
width: 28px; height: 28px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: transform 0.2s var(--spring-bounce);
}
.tweak-swatch:hover { transform: scale(calc(1 + 0.12 * var(--motion))); }
.tweak-swatch.active { border-color: var(--fg); transform: scale(calc(1 + 0.08 * var(--motion))); }
.seg {
display: flex;
background: var(--bg-2);
border-radius: var(--r-sm);
padding: 2px;
gap: 2px;
}
.seg button {
flex: 1;
padding: 5px 8px;
border-radius: calc(var(--r-sm) - 2px);
font-size: 12px;
color: var(--fg-muted);
transition: all 0.2s;
}
.seg button.active {
background: var(--bg-0);
color: var(--fg);
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
/* ==== Login ==== */
.login-shell {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(ellipse 700px 500px at 20% 20%, var(--accent-soft), transparent),
radial-gradient(ellipse 700px 600px at 80% 80%, hsl(calc(var(--accent-h) + 180) 60% 40% / 0.08), transparent),
var(--bg-0);
overflow: hidden;
z-index: 2000;
}
.login-card {
width: 420px;
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: var(--r-xl);
padding: 36px 34px;
box-shadow: var(--shadow-pop);
position: relative;
z-index: 2;
}
.login-brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
margin-bottom: 26px;
}
.login-brand-mark {
filter: drop-shadow(0 6px 20px var(--accent-glow));
}
.login-brand-name {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.02em;
}
.login-brand-tag {
font-size: 13px;
color: var(--fg-muted);
text-align: center;
}
.login-field { margin-bottom: 14px; }
.login-field-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--fg-dim);
margin-bottom: 5px;
}
.login-field input {
width: 100%;
padding: 10px 12px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--fg);
font-size: 14px;
outline: none;
transition: all 0.2s;
}
.login-field input:focus {
border-color: var(--accent);
background: var(--bg-1);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.login-submit {
width: 100%;
padding: 11px;
background: var(--accent);
color: var(--accent-fg);
border-radius: var(--r-sm);
font-size: 14px;
font-weight: 600;
margin-top: 6px;
transition: all 0.25s var(--spring-bounce);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.login-submit:hover {
transform: translateY(calc(-1px * var(--motion)));
box-shadow: 0 8px 20px -4px var(--accent-glow);
}
.login-submit:active { transform: scale(0.98); }
.login-divider {
display: flex;
align-items: center;
gap: 10px;
margin: 22px 0 16px;
font-size: 11px;
color: var(--fg-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.login-divider::before, .login-divider::after {
content: ''; flex: 1; height: 1px; background: var(--border);
}
.login-alt-row { display: flex; gap: 8px; margin-bottom: 14px; }
.login-alt-btn {
flex: 1;
padding: 9px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 12px;
font-weight: 500;
color: var(--fg);
transition: all 0.2s var(--spring-bounce);
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.login-alt-btn:hover { border-color: var(--accent); transform: translateY(calc(-1px * var(--motion))); }
.login-foot {
text-align: center;
font-size: 12px;
color: var(--fg-muted);
margin-top: 14px;
}
.login-foot a { color: var(--accent); cursor: pointer; }
/* Floating pyramids in login bg */
.login-float {
position: absolute;
opacity: 0.18;
animation: float-pyramid 8s infinite ease-in-out;
}
@keyframes float-pyramid {
0%, 100% { transform: translateY(0) rotate(var(--rot, 0deg)); }
50% { transform: translateY(-20px) rotate(calc(var(--rot, 0deg) + 10deg)); }
}
/* ==== Toast ==== */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-0);
border: 1px solid var(--border);
border-radius: var(--r-pill);
padding: 10px 18px;
font-size: 13px;
font-weight: 500;
box-shadow: var(--shadow-pop);
z-index: 1500;
display: flex;
align-items: center;
gap: 8px;
animation: slide-up 0.3s var(--spring-bounce);
}
/* ==== View switcher tabs (for demo) ==== */
.demo-switcher {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-0);
border: 1px solid var(--border);
border-radius: var(--r-pill);
padding: 4px;
display: flex;
gap: 2px;
z-index: 700;
box-shadow: var(--shadow-lg);
}
.demo-switcher button {
padding: 5px 12px;
border-radius: var(--r-pill);
font-size: 11px;
font-weight: 500;
color: var(--fg-muted);
transition: all 0.2s var(--spring-bounce);
}
.demo-switcher button.active {
background: var(--accent);
color: var(--accent-fg);
}
.demo-switcher button:hover:not(.active) { color: var(--fg); background: var(--bg-hover); }
+224
View File
@@ -0,0 +1,224 @@
/* ============================================
PYRAMID · Matrix Client
Design tokens + global styles
============================================ */
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap');
:root {
/* Type */
--font-sans: 'Geist', system-ui, -apple-system, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
/* Accent (tweakable) */
--accent-h: 42;
--accent-s: 95%;
--accent-l: 58%;
--accent: hsl(var(--accent-h) var(--accent-s) var(--accent-l));
--accent-soft: hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.14);
--accent-glow: hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.35);
--accent-fg: #0b0b0d;
/* Radius (tweakable) */
--r-sm: calc(var(--r-base) * 0.6);
--r-base: 12px;
--r-lg: calc(var(--r-base) * 1.5);
--r-xl: calc(var(--r-base) * 2);
--r-pill: 999px;
/* Density (tweakable) */
--density: 1;
--row-pad-y: calc(10px * var(--density));
--row-pad-x: calc(12px * var(--density));
--msg-gap: calc(16px * var(--density));
/* Animations (tweakable 0..1) */
--motion: 1;
--spring-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
--spring-soft: cubic-bezier(0.22, 1, 0.36, 1);
/* Dark theme (default) */
--bg-0: #0a0a0c;
--bg-1: #111114;
--bg-2: #17171c;
--bg-3: #1e1e25;
--bg-hover: #232330;
--bg-active: #2a2a38;
--border: #25252e;
--border-strong: #32323d;
--fg: #ececf0;
--fg-muted: #a4a4b0;
--fg-dim: #6f6f7d;
--danger: #e5484d;
--success: #30a46c;
--online: #4ade80;
--away: #facc15;
--busy: #f87171;
--shadow-lg: 0 12px 40px -8px rgba(0,0,0,0.6), 0 2px 8px -2px rgba(0,0,0,0.4);
--shadow-pop: 0 20px 60px -10px rgba(0,0,0,0.7), 0 4px 12px -4px rgba(0,0,0,0.5);
}
[data-theme="light"] {
--bg-0: #f5f4f0;
--bg-1: #ffffff;
--bg-2: #faf9f5;
--bg-3: #f0eeea;
--bg-hover: #e9e7e1;
--bg-active: #dfdcd3;
--border: #e5e2dc;
--border-strong: #d3cfc6;
--fg: #1a1a1f;
--fg-muted: #54545e;
--fg-dim: #8a8a92;
--shadow-lg: 0 12px 40px -8px rgba(30,25,15,0.12), 0 2px 8px -2px rgba(30,25,15,0.06);
--shadow-pop: 0 20px 60px -10px rgba(30,25,15,0.18), 0 4px 12px -4px rgba(30,25,15,0.08);
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
font-family: var(--font-sans);
background: var(--bg-0);
color: var(--fg);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
overflow: hidden;
transition: background 0.3s var(--spring-soft), color 0.3s var(--spring-soft);
}
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input, textarea { font-family: inherit; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
/* ==== App shell ==== */
.app {
display: grid;
grid-template-columns: 72px 260px 1fr;
height: 100vh;
background: var(--bg-0);
}
.app.right-open {
grid-template-columns: 72px 260px 1fr 280px;
}
.app.rail-collapsed {
grid-template-columns: 0 260px 1fr;
}
.app.rail-collapsed.right-open {
grid-template-columns: 0 260px 1fr 280px;
}
.app.rooms-collapsed {
grid-template-columns: 72px 0 1fr;
}
/* ==== Pyramid logo ==== */
.pyramid-logo {
display: inline-block;
position: relative;
}
/* ==== Keyframes ==== */
@keyframes pop-in {
0% { opacity: 0; transform: scale(0.85) translateY(4px); }
70% { transform: scale(1.03) translateY(0); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-right {
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 var(--accent-glow); }
100% { box-shadow: 0 0 0 10px transparent; }
}
@keyframes wiggle {
0%, 100% { transform: rotate(0); }
25% { transform: rotate(-3deg); }
75% { transform: rotate(3deg); }
}
@keyframes bounce-in {
0% { transform: scale(0.3); opacity: 0; }
50% { transform: scale(1.15); }
80% { transform: scale(0.95); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes voice-wave {
0%, 100% { transform: scaleY(0.4); }
50% { transform: scaleY(1); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* ==== Shared util ==== */
.icon-btn {
width: 32px; height: 32px;
display: inline-flex;
align-items: center; justify-content: center;
border-radius: var(--r-sm);
color: var(--fg-muted);
transition: all 0.18s var(--spring-soft);
flex-shrink: 0;
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--fg);
transform: scale(calc(1 + 0.05 * var(--motion)));
}
.icon-btn:active { transform: scale(calc(1 - 0.05 * var(--motion))); }
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--r-pill);
font-size: 11px;
font-weight: 500;
background: var(--bg-3);
color: var(--fg-muted);
}
.avatar {
display: inline-flex;
align-items: center; justify-content: center;
font-weight: 600;
color: white;
flex-shrink: 0;
user-select: none;
}
.presence-dot {
width: 10px; height: 10px;
border-radius: 50%;
border: 2px solid var(--bg-1);
position: absolute;
bottom: -2px; right: -2px;
}
.presence-dot.online { background: var(--online); }
.presence-dot.away { background: var(--away); }
.presence-dot.busy { background: var(--busy); }
.presence-dot.offline { background: var(--fg-dim); }