diff --git a/.gitignore b/.gitignore index c811e76..65c33d5 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,4 @@ yarn-error.log* next-env.d.ts # local notes -# notes.md +notes.md diff --git a/.notes.md b/.notes.md deleted file mode 100644 index 31906e9..0000000 --- a/.notes.md +++ /dev/null @@ -1,370 +0,0 @@ -# Code Style & Patterns - -Personal notes on code style for this project. - ---- - -## Comments - -### ✅ Do -```typescript -// Debounce rapid keypresses - nobody needs 60fps navigation -// AUR gets special amber styling because it's ✨special✨ -// Skip if already installed - no point reinstalling -// The vim keybindings that make us feel like hackers -``` - -### ❌ Don't -```typescript -/** - * Debounces rapid keypresses to prevent performance issues - * @param delay - The delay in milliseconds - * @returns void - */ - -// ───────────────────────────────────────────────────────────────── -// Section Header -// ───────────────────────────────────────────────────────────────── -``` - -### Rules -1. Single-line `//` comments, not JSDoc blocks -2. No decorative section dividers -3. Explain "why", not "what" -4. Occasional humor when it fits -5. Short comments - if you need a paragraph, simplify the code - ---- - -## Component Structure - -### Size Limits -- **Functions**: ~30 lines -- **Components**: ~150 lines -- **Files**: ~300 lines - -### Organization -- Logic in hooks, rendering in components -- One component per file -- Barrel files get one-liner comments - -```typescript -// Header area components -export { HowItWorks } from './HowItWorks'; -``` - -### File Naming -- `PascalCase.tsx` for components -- `camelCase.ts` for hooks/utilities -- `kebab-case` for directories - ---- - -## TypeScript - -```typescript -// Let inference work -const [selected, setSelected] = useState>(new Set()); - -// Destructure props -function AppItem({ app, isSelected }: AppItemProps) { ... } - -// Union types over enums -type DistroId = 'ubuntu' | 'debian' | 'arch'; - -// Avoid any -const data: any = fetchData(); // ❌ -``` - ---- - -## React Patterns - -### Memoization -```typescript -// Memo for expensive children with frequent parent re-renders -const AppItem = memo(function AppItem({ ... }) { ... }); - -// useMemo for expensive calculations -const filtered = useMemo(() => apps.filter(...), [apps, query]); - -// useCallback for handlers to memoized children -const handleToggle = useCallback((id) => { ... }, [deps]); -``` - -### State -```typescript -// Derive state, don't store computed values -const count = selected.size; // ✅ derived -const [count, setCount] = ... // ❌ redundant state - -// Keep state minimal and colocated -``` - -### Early Returns -```typescript -if (!apps.length) return null; -if (isLoading) return ; -// Then the happy path -``` - ---- - -## Accessibility - -### Keyboard Navigation -```typescript -// All interactive elements need keyboard support -onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') handleClick(); -}} - -// Focus management after modals/overlays -useEffect(() => { inputRef.current?.focus(); }, [isOpen]); -``` - -### Semantic HTML -```typescript -// Use correct elements - diff --git a/src/components/command/CommandDrawer.tsx b/src/components/command/CommandDrawer.tsx index cf7e72f..ed419f0 100644 --- a/src/components/command/CommandDrawer.tsx +++ b/src/components/command/CommandDrawer.tsx @@ -181,7 +181,13 @@ export function CommandDrawer({
$ - + {command}
diff --git a/src/components/command/ShortcutsBar.tsx b/src/components/command/ShortcutsBar.tsx index e3643f7..0d04a68 100644 --- a/src/components/command/ShortcutsBar.tsx +++ b/src/components/command/ShortcutsBar.tsx @@ -53,7 +53,7 @@ export function ShortcutsBar({ onKeyDown={handleKeyDown} placeholder="search..." className=" - w-20 sm:w-28 + w-28 sm:w-40 bg-transparent text-[var(--text-primary)] placeholder:text-[var(--text-muted)]/50 diff --git a/src/components/common/Tooltip.tsx b/src/components/common/Tooltip.tsx index 840395c..c7b7a59 100644 --- a/src/components/common/Tooltip.tsx +++ b/src/components/common/Tooltip.tsx @@ -1,62 +1,56 @@ 'use client'; -import React from 'react'; - -export interface TooltipData { - text: string; - x: number; - y: number; - width: number; - key: number; -} +import React, { useState, useEffect, useRef } from 'react'; +import { type TooltipState } from '@/hooks/useTooltip'; interface TooltipProps { - tooltip: TooltipData | null; - onEnter?: () => void; - onLeave?: () => void; + tooltip: TooltipState | null; + onMouseEnter: () => void; + onMouseLeave: () => void; } -// Floating tooltip with markdown rendering - follows the cursor around -export function Tooltip({ tooltip, onEnter, onLeave }: TooltipProps) { - if (!tooltip) return null; +export function Tooltip({ tooltip, onMouseEnter, onMouseLeave }: TooltipProps) { + const [current, setCurrent] = useState(null); + const [visible, setVisible] = useState(false); + const timeoutRef = useRef(null); - // Center horizontally relative to the element - const left = tooltip.x; - const top = tooltip.y; + useEffect(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + if (tooltip) { + // eslint-disable-next-line + setCurrent(tooltip); + requestAnimationFrame(() => setVisible(true)); + } else { + setVisible(false); + timeoutRef.current = setTimeout(() => setCurrent(null), 60); + } + + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [tooltip]); + + if (!current) return null; - // Helper to render markdown content const renderContent = (text: string) => { - // Split by **bold**, `code`, or [link](url) - return text.split(/(\*\*.*?\*\*|`.*?`|\[.*?\]\(.*?\))/g).map((part, i) => { - // Bold + return text.split(/(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g).map((part, i) => { if (part.startsWith('**') && part.endsWith('**')) { - return ( - - {part.slice(2, -2)} - - ); + return {part.slice(2, -2)}; } - // Code if (part.startsWith('`') && part.endsWith('`')) { - return ( - - {part.slice(1, -1)} - - ); + return {part.slice(1, -1)}; } - // Link if (part.startsWith('[') && part.includes('](') && part.endsWith(')')) { const match = part.match(/\[(.*?)\]\((.*?)\)/); if (match) { return ( - e.stopPropagation()} // Prevent triggering parent clicks - > + e.stopPropagation()}> {match[1]} ); @@ -66,30 +60,45 @@ export function Tooltip({ tooltip, onEnter, onLeave }: TooltipProps) { }); }; - // Hide tooltips on mobile - they don't work with touch return (
-
- {renderContent(tooltip.text)} - - {/* Arrow pointer */} +
+ {/* Clean tooltip bubble */}
+ className="px-3.5 py-2.5 rounded-lg shadow-lg overflow-hidden" + style={{ + minWidth: '300px', + maxWidth: '300px', + backgroundColor: 'var(--bg-secondary)', + border: '1px solid var(--border-primary)', + }} + > +

+ {renderContent(current.content)} +

+
+ + {/* Arrow */} +
+
+
); diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 052dd17..d289c38 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -1,5 +1,5 @@ // Shared components - tooltip, animations, skeleton -export { Tooltip, type TooltipData } from './Tooltip'; +export { Tooltip } from './Tooltip'; export { GlobalStyles } from './GlobalStyles'; export { LoadingSkeleton } from './LoadingSkeleton'; diff --git a/src/hooks/useKeyboardNavigation.ts b/src/hooks/useKeyboardNavigation.ts index c1f2933..6f0e2fd 100644 --- a/src/hooks/useKeyboardNavigation.ts +++ b/src/hooks/useKeyboardNavigation.ts @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { type Category } from '@/lib/data'; // What we're navigating to @@ -25,6 +25,12 @@ export function useKeyboardNavigation( ) { const [focusPos, setFocusPos] = useState(null); + // Track if focus was set via keyboard (to enable scroll) vs mouse (no scroll) + const fromKeyboard = useRef(false); + + // Track if focus mode is keyboard (for UI highlighting) + const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false); + /** Clear focus (e.g., when clicking outside) */ const clearFocus = useCallback(() => setFocusPos(null), []); @@ -34,12 +40,14 @@ export function useKeyboardNavigation( return navItems[focusPos.col]?.[focusPos.row] || null; }, [navItems, focusPos]); - /** Set focus position by item type and id */ + /** Set focus position by item type and id (from mouse - no scroll) */ const setFocusByItem = useCallback((type: 'category' | 'app', id: string) => { for (let col = 0; col < navItems.length; col++) { const colItems = navItems[col]; for (let row = 0; row < colItems.length; row++) { if (colItems[row].type === type && colItems[row].id === id) { + fromKeyboard.current = false; // Mouse selection - don't scroll + setIsKeyboardNavigating(false); // Disable focus ring setFocusPos({ col, row }); return; } @@ -79,6 +87,10 @@ export function useKeyboardNavigation( return; } + // Mark as keyboard navigation - will trigger scroll and focus ring + fromKeyboard.current = true; + setIsKeyboardNavigating(true); + // Navigate setFocusPos(prev => { if (!prev) return { col: 0, row: 0 }; @@ -117,16 +129,18 @@ export function useKeyboardNavigation( return () => window.removeEventListener('keydown', handleKeyDown); }, [navItems, focusPos, onToggleCategory, onToggleApp]); - /* Scroll focused item into view instantly */ + /* Scroll focused item into view - only when navigating via keyboard */ useEffect(() => { - if (!focusPos) return; + if (!focusPos || !fromKeyboard.current) return; const item = navItems[focusPos.col]?.[focusPos.row]; if (!item) return; - const el = document.querySelector( + // Find visible element among duplicates (mobile/desktop layouts both render same data-nav-id) + const elements = document.querySelectorAll( `[data-nav-id="${item.type}:${item.id}"]` ); + const el = Array.from(elements).find(e => e.offsetWidth > 0 && e.offsetHeight > 0); if (!el) return; @@ -142,5 +156,6 @@ export function useKeyboardNavigation( focusedItem, clearFocus, setFocusByItem, + isKeyboardNavigating, }; } diff --git a/src/hooks/useTooltip.ts b/src/hooks/useTooltip.ts new file mode 100644 index 0000000..4898f4d --- /dev/null +++ b/src/hooks/useTooltip.ts @@ -0,0 +1,101 @@ +'use client'; + +import { useState, useCallback, useRef, useEffect } from 'react'; + +export interface TooltipState { + content: string; + x: number; + y: number; +} + +/** + * Tooltip that stays open while hovering trigger or tooltip. + * - 450ms delay before showing + * - Stays open once shown (until mouse leaves both trigger and tooltip) + * - Dismiss on click/scroll/escape + */ +export function useTooltip() { + const [tooltip, setTooltip] = useState(null); + const showTimeout = useRef(null); + const hideTimeout = useRef(null); + const isOverTrigger = useRef(false); + const isOverTooltip = useRef(false); + + const cancel = useCallback(() => { + if (showTimeout.current) { + clearTimeout(showTimeout.current); + showTimeout.current = null; + } + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + hideTimeout.current = null; + } + }, []); + + const tryHide = useCallback(() => { + cancel(); + // Only hide if mouse is not over trigger or tooltip + hideTimeout.current = setTimeout(() => { + if (!isOverTrigger.current && !isOverTooltip.current) { + setTooltip(null); + } + }, 100); + }, [cancel]); + + const show = useCallback((content: string, e: React.MouseEvent) => { + const target = e.currentTarget as HTMLElement; + isOverTrigger.current = true; + cancel(); + + const rect = target.getBoundingClientRect(); + + showTimeout.current = setTimeout(() => { + setTooltip({ + content, + x: rect.left + rect.width / 2, + y: rect.top, + }); + }, 450); + }, [cancel]); + + const hide = useCallback(() => { + isOverTrigger.current = false; + tryHide(); + }, [tryHide]); + + const tooltipMouseEnter = useCallback(() => { + isOverTooltip.current = true; + cancel(); + }, [cancel]); + + const tooltipMouseLeave = useCallback(() => { + isOverTooltip.current = false; + tryHide(); + }, [tryHide]); + + useEffect(() => { + const dismiss = () => { + cancel(); + isOverTrigger.current = false; + isOverTooltip.current = false; + setTooltip(null); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') dismiss(); + }; + + window.addEventListener('mousedown', dismiss, true); + window.addEventListener('scroll', dismiss, true); + window.addEventListener('keydown', handleKeyDown); + + return () => { + cancel(); + window.removeEventListener('mousedown', dismiss, true); + window.removeEventListener('scroll', dismiss, true); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [cancel]); + + return { tooltip, show, hide, tooltipMouseEnter, tooltipMouseLeave }; +} diff --git a/src/lib/data.ts b/src/lib/data.ts index 6d9928d..fc310d7 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -255,11 +255,27 @@ export const apps: AppData[] = [ { id: 'conky', name: 'Conky', description: 'Highly configurable desktop system monitor', category: 'System', iconUrl: mdi('monitor-dashboard', '#FFFFFF'), targets: { ubuntu: 'conky-all', debian: 'conky-all', arch: 'conky', fedora: 'conky', opensuse: 'conky', nix: 'conky' }, unavailableReason: 'Conky is a system tool and not available via Flatpak or Snap.' }, ]; -// Categories in display order +// Categories in display order - beginner-friendly (popular first), balanced heights +// Popular/Consumer first, then Developer/Power-user categories export const categories: Category[] = [ - 'Web Browsers', 'Communication', 'Dev: Languages', 'Dev: Editors', 'Dev: Tools', - 'Terminal', 'CLI Tools', 'Media', 'Creative', 'Gaming', 'Office', - 'VPN & Network', 'Security', 'File Sharing', 'System' + // Row 1: Most popular consumer categories + 'Web Browsers', // 9 - everyone needs this first + 'Communication', // 8 - Discord, Telegram, etc. + 'Media', // 14 - VLC, Spotify, etc. + 'Gaming', // 10 - Steam, etc. + 'Office', // 11 - LibreOffice, etc. + // Row 2: Creative & System + 'Creative', // 10 - Blender, GIMP, etc. + 'System', // 13 - Utilities + 'File Sharing', // 9 - Syncthing, torrents + 'Security', // 6 - Passwords, VPN + 'VPN & Network', // 7 - ProtonVPN, etc. + // Row 3: Developer categories (larger, go last to fill columns) + 'Dev: Editors', // 9 - VS Code, etc. + 'Dev: Languages', // 10 - Python, Node, etc. + 'Dev: Tools', // 18 - Docker, Git, etc. + 'Terminal', // 9 - Alacritty, etc. + 'CLI Tools', // 21 - btop, fzf, etc. ]; // Get apps by category