mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
Add keyboard shortcuts UI and CONTRIBUTING.md
Introduce keyboard shortcuts to the dashboard and add contributor docs. Adds a reusable useKeyboardShortcuts hook (with formatShortcutKey), a KeyboardShortcutsHelp modal component, and integrates shortcuts into App.tsx (registering keys like ?, Escape, /, ArrowLeft/Right, l, d and wiring store actions). Adds related styles (.glass-heavy, .kbd) to index.css. Also adds a comprehensive CONTRIBUTING.md with setup, workflow, testing, and PR guidelines.
This commit is contained in:
+270
@@ -0,0 +1,270 @@
|
||||
# Contributing to Understand Anything
|
||||
|
||||
Thank you for your interest in contributing to Understand Anything! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
## 🌟 Ways to Contribute
|
||||
|
||||
- **Bug Reports**: Found a bug? Open an issue with detailed reproduction steps
|
||||
- **Feature Requests**: Have an idea? Share it in the issues section
|
||||
- **Documentation**: Improve or translate documentation
|
||||
- **Code**: Fix bugs, add features, or improve performance
|
||||
- **Testing**: Write tests to improve code coverage
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 22 (developed on v24)
|
||||
- pnpm >= 10 (pinned via `packageManager` field in root `package.json`)
|
||||
- Git for version control
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Fork and Clone**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Understand-Anything.git
|
||||
cd Understand-Anything
|
||||
```
|
||||
|
||||
2. **Install Dependencies**
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. **Build Core Package**
|
||||
```bash
|
||||
pnpm --filter @understand-anything/core build
|
||||
```
|
||||
|
||||
4. **Run Tests**
|
||||
```bash
|
||||
pnpm --filter @understand-anything/core test
|
||||
pnpm --filter @understand-anything/skill test
|
||||
```
|
||||
|
||||
5. **Start Dashboard (Optional)**
|
||||
```bash
|
||||
pnpm dev:dashboard
|
||||
```
|
||||
|
||||
## 📝 Development Workflow
|
||||
|
||||
### 1. Create a Branch
|
||||
|
||||
Create a descriptive branch name:
|
||||
```bash
|
||||
git checkout -b feat/my-feature # For new features
|
||||
git checkout -b fix/bug-description # For bug fixes
|
||||
git checkout -b docs/update-readme # For documentation
|
||||
```
|
||||
|
||||
### 2. Make Changes
|
||||
|
||||
- Write clean, readable code
|
||||
- Follow existing code style and conventions
|
||||
- Add tests for new functionality
|
||||
- Update documentation as needed
|
||||
|
||||
### 3. Test Your Changes
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm --filter @understand-anything/core test
|
||||
pnpm --filter @understand-anything/skill test
|
||||
|
||||
# Run linter
|
||||
pnpm lint
|
||||
|
||||
# Build packages
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 4. Commit Your Changes
|
||||
|
||||
Write clear, descriptive commit messages:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add keyboard shortcuts to dashboard"
|
||||
```
|
||||
|
||||
**Commit Message Convention:**
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation changes
|
||||
- `style:` - Code style changes (formatting, etc.)
|
||||
- `refactor:` - Code refactoring
|
||||
- `test:` - Adding or updating tests
|
||||
- `chore:` - Maintenance tasks
|
||||
|
||||
### 5. Push and Create Pull Request
|
||||
|
||||
```bash
|
||||
git push origin your-branch-name
|
||||
```
|
||||
|
||||
Then open a Pull Request on GitHub with:
|
||||
- Clear title describing the change
|
||||
- Detailed description of what changed and why
|
||||
- Link to related issues (if any)
|
||||
- Screenshots (for UI changes)
|
||||
|
||||
## 🧪 Testing Guidelines
|
||||
|
||||
### Writing Tests
|
||||
|
||||
- Use Vitest for testing
|
||||
- Place tests in `__tests__` directories or `*.test.ts` files
|
||||
- Aim for high test coverage for new features
|
||||
- Test edge cases and error conditions
|
||||
|
||||
Example test structure:
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('MyFeature', () => {
|
||||
it('should do something', () => {
|
||||
// Arrange
|
||||
const input = 'test';
|
||||
|
||||
// Act
|
||||
const result = myFunction(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe('expected');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Run tests for specific package
|
||||
pnpm --filter @understand-anything/core test
|
||||
|
||||
# Run tests in watch mode
|
||||
pnpm --filter @understand-anything/core test --watch
|
||||
```
|
||||
|
||||
## 📚 Code Style Guidelines
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Use TypeScript strict mode
|
||||
- Define explicit types for function parameters and return values
|
||||
- Avoid `any` type - use `unknown` if type is truly unknown
|
||||
- Use interfaces for object shapes
|
||||
- Use type aliases for unions and complex types
|
||||
|
||||
### Formatting
|
||||
|
||||
- The project uses ESLint for code quality
|
||||
- Consistent indentation (2 spaces)
|
||||
- Use meaningful variable and function names
|
||||
- Keep functions small and focused
|
||||
|
||||
### React/Dashboard
|
||||
|
||||
- Use functional components with hooks
|
||||
- Keep components focused and single-purpose
|
||||
- Use Zustand for state management
|
||||
- Follow the existing component structure
|
||||
|
||||
### File Organization
|
||||
|
||||
```
|
||||
understand-anything-plugin/
|
||||
├── packages/
|
||||
│ ├── core/ # Core analysis engine
|
||||
│ │ ├── src/
|
||||
│ │ └── package.json
|
||||
│ └── dashboard/ # React dashboard
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ ├── utils/
|
||||
│ │ └── store.ts
|
||||
│ └── package.json
|
||||
├── src/ # Plugin skills implementation
|
||||
├── agents/ # AI agent prompts
|
||||
└── skills/ # Skill definitions
|
||||
```
|
||||
|
||||
## 🌍 Translation Guidelines
|
||||
|
||||
### Adding a New Language
|
||||
|
||||
1. Create `README.{language-code}.md` (e.g., `README.fr-FR.md`)
|
||||
2. Translate all sections while maintaining formatting
|
||||
3. Update main `README.md` to include language link
|
||||
4. Keep technical terms in English where appropriate
|
||||
5. Ensure all links still work
|
||||
|
||||
Example:
|
||||
```markdown
|
||||
<a href="README.md">English</a> | <a href="README.fr-FR.md">Français</a>
|
||||
```
|
||||
|
||||
## 🐛 Bug Reports
|
||||
|
||||
When reporting bugs, include:
|
||||
|
||||
- **Description**: Clear description of the issue
|
||||
- **Steps to Reproduce**: Detailed steps to reproduce the bug
|
||||
- **Expected Behavior**: What you expected to happen
|
||||
- **Actual Behavior**: What actually happened
|
||||
- **Environment**: OS, Node version, pnpm version
|
||||
- **Screenshots**: If applicable
|
||||
- **Error Messages**: Full error output
|
||||
|
||||
## 💡 Feature Requests
|
||||
|
||||
When requesting features:
|
||||
|
||||
- **Use Case**: Describe the problem you're trying to solve
|
||||
- **Proposed Solution**: How you envision the feature working
|
||||
- **Alternatives**: Other solutions you've considered
|
||||
- **Additional Context**: Any other relevant information
|
||||
|
||||
## 📋 Pull Request Checklist
|
||||
|
||||
Before submitting a PR, ensure:
|
||||
|
||||
- [ ] Code follows the project's style guidelines
|
||||
- [ ] All tests pass (`pnpm test`)
|
||||
- [ ] New code has test coverage
|
||||
- [ ] Documentation is updated (if needed)
|
||||
- [ ] Commit messages follow convention
|
||||
- [ ] PR description clearly explains changes
|
||||
- [ ] No console.log or debug code left behind
|
||||
- [ ] Branch is up to date with main
|
||||
|
||||
## 🤝 Code Review Process
|
||||
|
||||
1. **Automated Checks**: CI runs tests and linting
|
||||
2. **Maintainer Review**: Project maintainers review the code
|
||||
3. **Feedback**: Address any requested changes
|
||||
4. **Approval**: Once approved, PR will be merged
|
||||
5. **Cleanup**: Delete your branch after merge
|
||||
|
||||
## 📞 Getting Help
|
||||
|
||||
- **Issues**: For bugs and feature requests
|
||||
- **Discussions**: For questions and general discussion
|
||||
- **Documentation**: Check existing docs first
|
||||
|
||||
## 📄 License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
## 🙏 Recognition
|
||||
|
||||
Contributors will be recognized in:
|
||||
- GitHub contributors list
|
||||
- Release notes (for significant contributions)
|
||||
- Special mentions for exceptional contributions
|
||||
|
||||
---
|
||||
|
||||
**Thank you for contributing to Understand Anything! Your contributions help make code understanding accessible to everyone.** 🚀
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { validateGraph } from "@understand-anything/core/schema";
|
||||
import { useDashboardStore } from "./store";
|
||||
import GraphView from "./components/GraphView";
|
||||
@@ -10,6 +10,9 @@ import DiffToggle from "./components/DiffToggle";
|
||||
import LearnPanel from "./components/LearnPanel";
|
||||
import PersonaSelector from "./components/PersonaSelector";
|
||||
import ProjectOverview from "./components/ProjectOverview";
|
||||
import KeyboardShortcutsHelp from "./components/KeyboardShortcutsHelp";
|
||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import type { KeyboardShortcut } from "./hooks/useKeyboardShortcuts";
|
||||
|
||||
function App() {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
@@ -20,7 +23,106 @@ function App() {
|
||||
const codeViewerOpen = useDashboardStore((s) => s.codeViewerOpen);
|
||||
const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer);
|
||||
const setDiffOverlay = useDashboardStore((s) => s.setDiffOverlay);
|
||||
const selectNode = useDashboardStore((s) => s.selectNode);
|
||||
const toggleLayers = useDashboardStore((s) => s.toggleLayers);
|
||||
const toggleDiffMode = useDashboardStore((s) => s.toggleDiffMode);
|
||||
const stopTour = useDashboardStore((s) => s.stopTour);
|
||||
const nextTourStep = useDashboardStore((s) => s.nextTourStep);
|
||||
const prevTourStep = useDashboardStore((s) => s.prevTourStep);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
|
||||
|
||||
// Define keyboard shortcuts
|
||||
const shortcuts = useMemo<KeyboardShortcut[]>(
|
||||
() => [
|
||||
// Help
|
||||
{
|
||||
key: "?",
|
||||
shiftKey: true,
|
||||
description: "Show keyboard shortcuts",
|
||||
action: () => setShowKeyboardHelp((prev) => !prev),
|
||||
category: "General",
|
||||
},
|
||||
// Navigation
|
||||
{
|
||||
key: "Escape",
|
||||
description: "Close panels and modals",
|
||||
action: () => {
|
||||
if (showKeyboardHelp) {
|
||||
setShowKeyboardHelp(false);
|
||||
} else if (codeViewerOpen) {
|
||||
closeCodeViewer();
|
||||
} else if (selectedNodeId) {
|
||||
selectNode(null);
|
||||
} else if (tourActive) {
|
||||
stopTour();
|
||||
}
|
||||
},
|
||||
category: "Navigation",
|
||||
},
|
||||
{
|
||||
key: "/",
|
||||
description: "Focus search bar",
|
||||
action: () => {
|
||||
const searchInput = document.querySelector<HTMLInputElement>(
|
||||
'input[placeholder*="Search"]'
|
||||
);
|
||||
searchInput?.focus();
|
||||
},
|
||||
category: "Navigation",
|
||||
},
|
||||
// Tour controls
|
||||
{
|
||||
key: "ArrowRight",
|
||||
description: "Next tour step",
|
||||
action: () => {
|
||||
if (tourActive) {
|
||||
nextTourStep();
|
||||
}
|
||||
},
|
||||
category: "Tour",
|
||||
},
|
||||
{
|
||||
key: "ArrowLeft",
|
||||
description: "Previous tour step",
|
||||
action: () => {
|
||||
if (tourActive) {
|
||||
prevTourStep();
|
||||
}
|
||||
},
|
||||
category: "Tour",
|
||||
},
|
||||
// View toggles
|
||||
{
|
||||
key: "l",
|
||||
description: "Toggle layer visualization",
|
||||
action: toggleLayers,
|
||||
category: "View",
|
||||
},
|
||||
{
|
||||
key: "d",
|
||||
description: "Toggle diff mode",
|
||||
action: toggleDiffMode,
|
||||
category: "View",
|
||||
},
|
||||
],
|
||||
[
|
||||
showKeyboardHelp,
|
||||
codeViewerOpen,
|
||||
selectedNodeId,
|
||||
tourActive,
|
||||
closeCodeViewer,
|
||||
selectNode,
|
||||
stopTour,
|
||||
nextTourStep,
|
||||
prevTourStep,
|
||||
toggleLayers,
|
||||
toggleDiffMode,
|
||||
]
|
||||
);
|
||||
|
||||
// Register keyboard shortcuts
|
||||
useKeyboardShortcuts(shortcuts, !showKeyboardHelp);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/knowledge-graph.json")
|
||||
@@ -91,6 +193,25 @@ function App() {
|
||||
<div className="flex items-center gap-4">
|
||||
<DiffToggle />
|
||||
<LayerLegend />
|
||||
<button
|
||||
onClick={() => setShowKeyboardHelp(true)}
|
||||
className="text-text-muted hover:text-gold transition-colors"
|
||||
title="Keyboard shortcuts (Shift + ?)"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -135,6 +256,14 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard shortcuts help modal */}
|
||||
{showKeyboardHelp && (
|
||||
<KeyboardShortcutsHelp
|
||||
shortcuts={shortcuts}
|
||||
onClose={() => setShowKeyboardHelp(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { useEffect } from "react";
|
||||
import type { KeyboardShortcut } from "../hooks/useKeyboardShortcuts";
|
||||
import { formatShortcutKey } from "../hooks/useKeyboardShortcuts";
|
||||
|
||||
interface KeyboardShortcutsHelpProps {
|
||||
shortcuts: KeyboardShortcut[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function KeyboardShortcutsHelp({
|
||||
shortcuts,
|
||||
onClose,
|
||||
}: KeyboardShortcutsHelpProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
// Group shortcuts by category
|
||||
const groupedShortcuts = shortcuts.reduce((acc, shortcut) => {
|
||||
if (!acc[shortcut.category]) {
|
||||
acc[shortcut.category] = [];
|
||||
}
|
||||
acc[shortcut.category].push(shortcut);
|
||||
return acc;
|
||||
}, {} as Record<string, KeyboardShortcut[]>);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="glass rounded-lg shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-auto m-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 glass-heavy border-b border-border-subtle px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-serif text-text-primary">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Press <kbd className="kbd">?</kbd> anytime to toggle this help
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts list */}
|
||||
<div className="p-6 space-y-6">
|
||||
{Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-sm font-semibold text-gold uppercase tracking-wider mb-3">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{categoryShortcuts.map((shortcut, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between py-2 px-3 rounded hover:bg-elevated transition-colors"
|
||||
>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
<kbd className="kbd">{formatShortcutKey(shortcut)}</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 glass-heavy border-t border-border-subtle px-6 py-3 text-center">
|
||||
<p className="text-xs text-text-muted">
|
||||
Press <kbd className="kbd">ESC</kbd> to close
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
description: string;
|
||||
action: () => void;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(
|
||||
shortcuts: KeyboardShortcut[],
|
||||
enabled = true
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
for (const shortcut of shortcuts) {
|
||||
const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase();
|
||||
const ctrlMatches = shortcut.ctrlKey ? event.ctrlKey : !event.ctrlKey;
|
||||
const shiftMatches = shortcut.shiftKey ? event.shiftKey : !event.shiftKey;
|
||||
const altMatches = shortcut.altKey ? event.altKey : !event.altKey;
|
||||
const metaMatches = shortcut.metaKey ? event.metaKey : !event.metaKey;
|
||||
|
||||
if (keyMatches && ctrlMatches && shiftMatches && altMatches && metaMatches) {
|
||||
// Prevent default for shortcuts that might conflict with browser
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
event.preventDefault();
|
||||
}
|
||||
shortcut.action();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [shortcuts, enabled]);
|
||||
}
|
||||
|
||||
export function formatShortcutKey(shortcut: KeyboardShortcut): string {
|
||||
const keys: string[] = [];
|
||||
|
||||
if (shortcut.ctrlKey || shortcut.metaKey) {
|
||||
keys.push(navigator.platform.includes("Mac") ? "⌘" : "Ctrl");
|
||||
}
|
||||
if (shortcut.shiftKey) keys.push("⇧");
|
||||
if (shortcut.altKey) keys.push(navigator.platform.includes("Mac") ? "⌥" : "Alt");
|
||||
|
||||
keys.push(shortcut.key.toUpperCase());
|
||||
|
||||
return keys.join(" + ");
|
||||
}
|
||||
@@ -71,6 +71,31 @@ body {
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.glass-heavy {
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
border: 1px solid rgba(212, 165, 116, 0.15);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Keyboard shortcut key styling */
|
||||
.kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-gold);
|
||||
background: rgba(212, 165, 116, 0.1);
|
||||
border: 1px solid rgba(212, 165, 116, 0.3);
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 1px 0 rgba(212, 165, 116, 0.2);
|
||||
}
|
||||
|
||||
/* Animation keyframes */
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
|
||||
Reference in New Issue
Block a user