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:
+478
@@ -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 />);
|
||||
Reference in New Issue
Block a user