mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
feat: add command palette (Ctrl+K / Cmd+K)
Fuzzy-searchable command palette accessible from anywhere via Ctrl+K (Windows/Linux) or Cmd+K (macOS). Supports keyboard navigation with arrow keys, Enter to select, and Escape to close. Available commands: - Switch between sessions and projects - Create new sessions and scratchpads - Pin, archive, and duplicate the current session - Open settings, project settings, and app data folder - Toggle terminal - Switch theme (dark/light/system) - Add new projects Implementation: - New CommandPalette component with glass surface and glow border - Palette entry/exit CSS animations - Capture-phase Escape handler to prevent leaking to other overlays - Commands built dynamically from workspace state - Results grouped by category with fuzzy scoring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+39
-1
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AppShell } from '@renderer/components/AppShell';
|
||||
import { ActivityPanel } from '@renderer/components/ActivityPanel';
|
||||
import { ChatPane } from '@renderer/components/ChatPane';
|
||||
import { CommandPalette } from '@renderer/components/CommandPalette';
|
||||
import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal';
|
||||
import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
||||
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
||||
@@ -112,6 +113,7 @@ export default function App() {
|
||||
const [projectSettingsId, setProjectSettingsId] = useState<string>();
|
||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
|
||||
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
|
||||
// Terminal state
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
@@ -264,13 +266,17 @@ export default function App() {
|
||||
if (hasPendingDiscoveries) setShowDiscoveryModal(true);
|
||||
}, [hasPendingDiscoveries]);
|
||||
|
||||
// Terminal: Ctrl+` toggle + track running state
|
||||
// Terminal: Ctrl+` toggle + Command palette: Ctrl+K / Cmd+K
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === '`') {
|
||||
e.preventDefault();
|
||||
setTerminalOpen((prev) => !prev);
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setCommandPaletteOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
@@ -607,6 +613,38 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{commandPaletteOpen && workspace && (
|
||||
<CommandPalette
|
||||
workspace={workspace}
|
||||
onClose={() => setCommandPaletteOpen(false)}
|
||||
onSelectSession={(sessionId) => {
|
||||
void api.selectSession(sessionId);
|
||||
}}
|
||||
onSelectProject={(projectId) => {
|
||||
void api.selectProject(projectId);
|
||||
}}
|
||||
onNewSession={(projectId) => {
|
||||
setNewSessionProjectId(projectId);
|
||||
}}
|
||||
onCreateScratchpad={handleCreateScratchpad}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
|
||||
onToggleTerminal={handleTerminalToggle}
|
||||
onSetTheme={(theme) => void api.setTheme(theme)}
|
||||
onDuplicateSession={(sessionId) => {
|
||||
void api.duplicateSession({ sessionId });
|
||||
}}
|
||||
onPinSession={(sessionId, isPinned) => {
|
||||
void api.setSessionPinned({ sessionId, isPinned });
|
||||
}}
|
||||
onArchiveSession={(sessionId, isArchived) => {
|
||||
void api.setSessionArchived({ sessionId, isArchived });
|
||||
}}
|
||||
onAddProject={() => void api.addProject()}
|
||||
onOpenAppDataFolder={() => void api.openAppDataFolder()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Archive,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
MessageSquare,
|
||||
Monitor,
|
||||
Moon,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { AppearanceTheme } from '@shared/domain/tooling';
|
||||
import { isScratchpadProject } from '@shared/domain/project';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
interface PaletteCommand {
|
||||
id: string;
|
||||
label: string;
|
||||
category: string;
|
||||
keywords?: string;
|
||||
shortcut?: string;
|
||||
icon: React.ReactNode;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export interface CommandPaletteProps {
|
||||
workspace: WorkspaceState;
|
||||
onClose: () => void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onSelectProject: (projectId: string) => void;
|
||||
onNewSession: (projectId: string) => void;
|
||||
onCreateScratchpad: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenProjectSettings: (projectId: string) => void;
|
||||
onToggleTerminal: () => void;
|
||||
onSetTheme: (theme: AppearanceTheme) => void;
|
||||
onDuplicateSession: (sessionId: string) => void;
|
||||
onPinSession: (sessionId: string, isPinned: boolean) => void;
|
||||
onArchiveSession: (sessionId: string, isArchived: boolean) => void;
|
||||
onAddProject: () => void;
|
||||
onOpenAppDataFolder: () => void;
|
||||
}
|
||||
|
||||
/** Score how well `query` matches `text` (and optional `keywords`). 0 = no match. */
|
||||
function matchScore(query: string, text: string, keywords?: string): number {
|
||||
if (!query) return 1;
|
||||
const q = query.toLowerCase();
|
||||
const t = text.toLowerCase();
|
||||
|
||||
if (t.startsWith(q)) return 4;
|
||||
if (t.split(/\s+/).some((w) => w.startsWith(q))) return 3;
|
||||
if (t.includes(q)) return 2;
|
||||
if (keywords?.toLowerCase().includes(q)) return 1.5;
|
||||
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
if (tokens.length > 1) {
|
||||
const combined = `${t} ${keywords?.toLowerCase() ?? ''}`;
|
||||
if (tokens.every((tok) => combined.includes(tok))) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const ICON = 'size-4';
|
||||
|
||||
export function CommandPalette({
|
||||
workspace,
|
||||
onClose,
|
||||
onSelectSession,
|
||||
onSelectProject,
|
||||
onNewSession,
|
||||
onCreateScratchpad,
|
||||
onOpenSettings,
|
||||
onOpenProjectSettings,
|
||||
onToggleTerminal,
|
||||
onSetTheme,
|
||||
onDuplicateSession,
|
||||
onPinSession,
|
||||
onArchiveSession,
|
||||
onAddProject,
|
||||
onOpenAppDataFolder,
|
||||
}: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Intercept Escape in capture phase so it doesn't leak to other overlays
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape, true);
|
||||
return () => document.removeEventListener('keydown', handleEscape, true);
|
||||
}, [onClose]);
|
||||
|
||||
const selectedSession = useMemo(() => {
|
||||
const id = workspace.selectedSessionId;
|
||||
return id ? workspace.sessions.find((s) => s.id === id) : undefined;
|
||||
}, [workspace.sessions, workspace.selectedSessionId]);
|
||||
|
||||
const selectedProject = useMemo(() => {
|
||||
const id = workspace.selectedProjectId;
|
||||
return id ? workspace.projects.find((p) => p.id === id) : undefined;
|
||||
}, [workspace.projects, workspace.selectedProjectId]);
|
||||
|
||||
const commands = useMemo<PaletteCommand[]>(() => {
|
||||
const cmds: PaletteCommand[] = [];
|
||||
|
||||
// ── Sessions ──
|
||||
const sessions = workspace.sessions
|
||||
.filter((s) => !s.isArchived)
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
|
||||
for (const s of sessions) {
|
||||
const project = workspace.projects.find((p) => p.id === s.projectId);
|
||||
const isCurrent = s.id === workspace.selectedSessionId;
|
||||
cmds.push({
|
||||
id: `session-${s.id}`,
|
||||
label: `${s.title}${isCurrent ? ' (current)' : ''}`,
|
||||
category: 'Sessions',
|
||||
keywords: `switch ${project?.name ?? ''} ${s.status}`,
|
||||
icon: <MessageSquare className={ICON} />,
|
||||
action: () => onSelectSession(s.id),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
const defaultProjectId =
|
||||
workspace.selectedProjectId ??
|
||||
workspace.projects.find((p) => !isScratchpadProject(p))?.id;
|
||||
|
||||
if (defaultProjectId) {
|
||||
cmds.push({
|
||||
id: 'new-session',
|
||||
label: 'New Session',
|
||||
category: 'Actions',
|
||||
keywords: 'create start',
|
||||
icon: <Plus className={ICON} />,
|
||||
action: () => onNewSession(defaultProjectId),
|
||||
});
|
||||
}
|
||||
|
||||
cmds.push({
|
||||
id: 'new-scratchpad',
|
||||
label: 'Quick Scratchpad',
|
||||
category: 'Actions',
|
||||
keywords: 'create new scratch quick note',
|
||||
icon: <Sparkles className={ICON} />,
|
||||
action: onCreateScratchpad,
|
||||
});
|
||||
|
||||
// ── Current session ──
|
||||
if (selectedSession) {
|
||||
cmds.push({
|
||||
id: 'duplicate-session',
|
||||
label: 'Duplicate Session',
|
||||
category: 'Session',
|
||||
keywords: 'copy clone',
|
||||
icon: <Copy className={ICON} />,
|
||||
action: () => onDuplicateSession(selectedSession.id),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'pin-session',
|
||||
label: selectedSession.isPinned ? 'Unpin Session' : 'Pin Session',
|
||||
category: 'Session',
|
||||
keywords: 'pin unpin sticky',
|
||||
icon: selectedSession.isPinned ? <PinOff className={ICON} /> : <Pin className={ICON} />,
|
||||
action: () => onPinSession(selectedSession.id, !selectedSession.isPinned),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'archive-session',
|
||||
label: 'Archive Session',
|
||||
category: 'Session',
|
||||
keywords: 'archive hide remove',
|
||||
icon: <Archive className={ICON} />,
|
||||
action: () => onArchiveSession(selectedSession.id, true),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Projects ──
|
||||
const userProjects = workspace.projects.filter((p) => !isScratchpadProject(p));
|
||||
for (const p of userProjects) {
|
||||
const isCurrent = p.id === workspace.selectedProjectId;
|
||||
cmds.push({
|
||||
id: `project-${p.id}`,
|
||||
label: `${p.name}${isCurrent ? ' (current)' : ''}`,
|
||||
category: 'Projects',
|
||||
keywords: `switch folder ${p.path}`,
|
||||
icon: <FolderOpen className={ICON} />,
|
||||
action: () => onSelectProject(p.id),
|
||||
});
|
||||
}
|
||||
cmds.push({
|
||||
id: 'add-project',
|
||||
label: 'Add Project',
|
||||
category: 'Projects',
|
||||
keywords: 'folder new open browse',
|
||||
icon: <FolderPlus className={ICON} />,
|
||||
action: onAddProject,
|
||||
});
|
||||
|
||||
// ── General ──
|
||||
cmds.push({
|
||||
id: 'settings',
|
||||
label: 'Open Settings',
|
||||
category: 'General',
|
||||
keywords: 'preferences config options',
|
||||
icon: <Settings className={ICON} />,
|
||||
action: onOpenSettings,
|
||||
});
|
||||
|
||||
if (selectedProject && !isScratchpadProject(selectedProject)) {
|
||||
cmds.push({
|
||||
id: 'project-settings',
|
||||
label: `Project Settings — ${selectedProject.name}`,
|
||||
category: 'General',
|
||||
keywords: 'project config options customization',
|
||||
icon: <Settings className={ICON} />,
|
||||
action: () => onOpenProjectSettings(selectedProject.id),
|
||||
});
|
||||
}
|
||||
|
||||
cmds.push({
|
||||
id: 'toggle-terminal',
|
||||
label: 'Toggle Terminal',
|
||||
category: 'General',
|
||||
keywords: 'terminal console shell command',
|
||||
shortcut: 'Ctrl+`',
|
||||
icon: <Terminal className={ICON} />,
|
||||
action: onToggleTerminal,
|
||||
});
|
||||
|
||||
cmds.push({
|
||||
id: 'app-data',
|
||||
label: 'Open App Data Folder',
|
||||
category: 'General',
|
||||
keywords: 'data storage files folder workspace',
|
||||
icon: <FolderOpen className={ICON} />,
|
||||
action: onOpenAppDataFolder,
|
||||
});
|
||||
|
||||
// ── Theme ──
|
||||
cmds.push({
|
||||
id: 'theme-dark',
|
||||
label: 'Dark Theme',
|
||||
category: 'Theme',
|
||||
keywords: 'appearance dark mode night',
|
||||
icon: <Moon className={ICON} />,
|
||||
action: () => onSetTheme('dark'),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'theme-light',
|
||||
label: 'Light Theme',
|
||||
category: 'Theme',
|
||||
keywords: 'appearance light mode day',
|
||||
icon: <Sun className={ICON} />,
|
||||
action: () => onSetTheme('light'),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'theme-system',
|
||||
label: 'System Theme',
|
||||
category: 'Theme',
|
||||
keywords: 'appearance auto system follow',
|
||||
icon: <Monitor className={ICON} />,
|
||||
action: () => onSetTheme('system'),
|
||||
});
|
||||
|
||||
return cmds;
|
||||
}, [
|
||||
workspace, selectedSession, selectedProject,
|
||||
onSelectSession, onSelectProject, onNewSession, onCreateScratchpad,
|
||||
onOpenSettings, onOpenProjectSettings, onToggleTerminal, onSetTheme,
|
||||
onDuplicateSession, onPinSession, onArchiveSession, onAddProject,
|
||||
onOpenAppDataFolder,
|
||||
]);
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!query.trim()) return commands;
|
||||
return commands
|
||||
.map((cmd) => ({ cmd, score: matchScore(query, cmd.label, cmd.keywords) }))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ cmd }) => cmd);
|
||||
}, [commands, query]);
|
||||
|
||||
const groupedCommands = useMemo(() => {
|
||||
const groups: { category: string; commands: PaletteCommand[] }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const cmd of filteredCommands) {
|
||||
if (!seen.has(cmd.category)) {
|
||||
seen.add(cmd.category);
|
||||
groups.push({ category: cmd.category, commands: [] });
|
||||
}
|
||||
groups.find((g) => g.category === cmd.category)!.commands.push(cmd);
|
||||
}
|
||||
return groups;
|
||||
}, [filteredCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [query]);
|
||||
|
||||
const executeCommand = useCallback(
|
||||
(cmd: PaletteCommand) => {
|
||||
onClose();
|
||||
cmd.action();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (filteredCommands.length > 0) {
|
||||
setSelectedIndex((i) => Math.min(i + 1, filteredCommands.length - 1));
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const cmd = filteredCommands[selectedIndex];
|
||||
if (cmd) executeCommand(cmd);
|
||||
}
|
||||
},
|
||||
[filteredCommands, selectedIndex, executeCommand],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const item = listRef.current?.querySelector(`[data-palette-index="${selectedIndex}"]`);
|
||||
item?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
let flatIndex = 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="palette-backdrop-enter fixed inset-0 z-[60] flex justify-center bg-[#07080e]/80 pt-[18vh] backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
>
|
||||
<div
|
||||
className="palette-enter glow-border flex h-fit max-h-[min(420px,60vh)] w-full max-w-xl flex-col overflow-hidden rounded-xl bg-[var(--color-surface-1)] shadow-[0_16px_64px_rgba(0,0,0,0.5)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 border-b border-[var(--color-border)] px-4">
|
||||
<Search className="size-4 shrink-0 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="flex-1 bg-transparent py-3.5 text-[14px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-muted)]"
|
||||
placeholder="Type a command…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label="Search commands"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setQuery('')}
|
||||
type="button"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto py-1.5" role="listbox">
|
||||
{groupedCommands.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-[13px] text-[var(--color-text-muted)]">
|
||||
No matching commands
|
||||
</div>
|
||||
) : (
|
||||
groupedCommands.map((group) => (
|
||||
<div key={group.category}>
|
||||
<div className="px-4 pb-1 pt-2.5 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
{group.category}
|
||||
</div>
|
||||
{group.commands.map((cmd) => {
|
||||
const index = flatIndex++;
|
||||
const isSelected = index === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
key={cmd.id}
|
||||
data-palette-index={index}
|
||||
className={`flex w-full items-center gap-3 px-4 py-2 text-left text-[13px] transition-colors ${
|
||||
isSelected
|
||||
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-glass-hover)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
onClick={() => executeCommand(cmd)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
isSelected
|
||||
? 'text-[var(--color-text-accent)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
}
|
||||
>
|
||||
{cmd.icon}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{cmd.label}</span>
|
||||
{cmd.shortcut && (
|
||||
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-0)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||
{cmd.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hints */}
|
||||
<div className="flex items-center gap-4 border-t border-[var(--color-border)] px-4 py-2 text-[11px] text-[var(--color-text-muted)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px]">
|
||||
↑↓
|
||||
</kbd>
|
||||
navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px]">
|
||||
↵
|
||||
</kbd>
|
||||
select
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px]">
|
||||
esc
|
||||
</kbd>
|
||||
close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -521,6 +521,27 @@ body {
|
||||
animation: ambient-pulse 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Command palette ──────────────────────────────────────────── */
|
||||
|
||||
@keyframes palette-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.palette-enter {
|
||||
animation: palette-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes palette-backdrop-enter {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
.palette-backdrop-enter {
|
||||
animation: palette-backdrop-enter 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* ── Markdown prose styles ───────────────────────────────────── */
|
||||
|
||||
.markdown-content {
|
||||
|
||||
Reference in New Issue
Block a user