refined UI/UX (new fonts, focus states, tooltips)

This commit is contained in:
N1C4T
2026-01-06 21:21:44 +04:00
parent 6c9b2123dd
commit 6d4fcca6c5
14 changed files with 270 additions and 472 deletions

2
.gitignore vendored
View File

@@ -41,4 +41,4 @@ yarn-error.log*
next-env.d.ts
# local notes
# notes.md
notes.md

370
.notes.md
View File

@@ -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<Set<string>>(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 <Skeleton />;
// 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
<button> not <div onClick>
<nav>, <main>, <aside>, <article>
role="button" tabIndex={0} if custom element needed
```
### Focus Visibility
```css
/* Always visible focus states */
:focus-visible { outline: 2px solid var(--accent); }
```
---
## Performance
### Bundle Size
- Question every new dependency
- Use dynamic imports for heavy components
- Tree-shake: import `{ Check }` not `import * as Icons`
### Rendering
```typescript
// Avoid layout thrashing
const rect = element.getBoundingClientRect(); // batch reads
element.style.top = rect.top + 'px'; // then writes
// Debounce expensive handlers
const debouncedSearch = useMemo(
() => debounce(handleSearch, 300),
[]
);
```
### Loading States
- Skeleton loaders for structure
- Avoid layout shift (reserve space)
- Show progress for long operations
---
## Shell Script Security
```typescript
// ALWAYS escape user input
`install_pkg "${escapeShellString(app.name)}" "${pkg}"`
// Scripts start safe
set -euo pipefail
// No eval. Ever.
// No backticks for command substitution
```
### Error Messages
```bash
# Casual but informative
error "Run as regular user, not root."
warn "Update failed, continuing..."
# Not robotic
error "ERROR: Elevated privileges detected." # ❌
```
---
## Naming
| Type | Pattern | Example |
|------|---------|---------|
| Boolean | `is`, `has`, `should`, `can` | `isAur`, `hasYay` |
| Handler | `handle` prefix | `handleClick` |
| Setter | `set` prefix | `setTheme` |
| Ref | `Ref` suffix | `inputRef` |
**Concise**: `isAur` not `isArchUserRepositoryPackage`
---
## CSS & Theming
### Variables
```css
--bg-primary, --bg-secondary, --bg-tertiary
--text-primary, --text-secondary, --text-muted
--accent, --border-primary
```
### Rules
- Use variables, never hardcode colors
- `transition-colors` not `transition-all`
- Mobile-first breakpoints: `sm:`, `md:`, `lg:`
---
## Responsive Design
### Touch Targets
- Minimum 44x44px for touch
- Adequate spacing between targets
### Breakpoints
```css
/* Mobile first */
.element { /* base mobile */ }
@media (min-width: 640px) { /* sm */ }
@media (min-width: 768px) { /* md */ }
@media (min-width: 1024px) { /* lg */ }
```
---
## Error Handling
```typescript
// User-facing: friendly
toast.error("Couldn't copy. Try again?")
// Console: detailed
console.error('Clipboard API failed:', error);
// Never swallow
try { ... } catch { } // ❌
try { ... } catch (e) { console.error(e); } // ✅
```
---
## Dependencies
### Before Adding
1. Can we do it in <20 lines ourselves?
2. What's the bundle size impact?
3. Is it maintained?
4. Do we need the whole package or just one function?
### Existing Stack
- `framer-motion` - animations
- `lucide-react` - icons
- `gsap` - complex animations (use sparingly)
- `clsx` + `tailwind-merge` - className utilities
---
## Adding New Features
### New App
1. Add to `data.ts` in correct category
2. Include all available distro targets
3. Add `unavailableReason` with helpful links
4. Test generated scripts
### New Distro
1. Add to `DistroId` type
2. Add to `distros` array
3. Create script generator in `lib/scripts/`
4. Update `generateInstallScript.ts`
### New Component
1. Create in appropriate directory
2. Add to barrel `index.ts`
3. Keep under 150 lines
4. Extract hooks if logic-heavy
---
## Git Commits
```bash
feat: add keyboard navigation
fix: prevent hydration mismatch
refactor: extract AUR logic to hook
style: adjust tooltip positioning
docs: update README shortcuts
chore: bump dependencies
```
---
## Debugging
### Console Patterns
```typescript
// Conditional logging
if (process.env.NODE_ENV === 'development') {
console.log('Debug:', data);
}
// Structured logging
console.group('useLinuxInit');
console.log('distro:', distro);
console.log('selected:', selected.size);
console.groupEnd();
```
### React DevTools
- Check for unnecessary re-renders
- Verify memo is working
- Profile expensive components
---
## Anti-Patterns
| Don't | Do Instead |
|-------|------------|
| `any` type | Proper types or `unknown` |
| Prop drilling 5+ | Context or composition |
| `useEffect` for derived state | Compute during render |
| Giant switch | Object lookup |
| `// TODO: fix later` | Fix now or create issue |
| `transition-all` | `transition-colors` |
| Hardcoded colors | CSS variables |
| Store computed state | Derive it |
---
## Quick Reference
| ✅ Do | ❌ Don't |
|------|---------|
| `// brief comment` | `/** Long JSDoc */` |
| Early return | Nested ifs |
| `isAur` | `isArchUserRepositoryPackage` |
| Extract hooks | 500-line components |
| CSS variables | Hardcoded colors |
| `escapeShellString()` | String concatenation |
| Union types | Enums |
| Derive state | Store computed |
| `<button>` | `<div onClick>` |
| 44px touch targets | Tiny buttons |
---
## Philosophy
Code should feel like it was written by someone who:
- Uses Arch btw
- Has vim keybindings muscle memory
- Ships fast, iterates faster
- Writes for humans first
Not like enterprise software following a 200-page style guide.

View File

@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Geist, Geist_Mono, JetBrains_Mono, Open_Sans } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/hooks/useTheme";
@@ -13,6 +13,17 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
const jetbrainsMono = JetBrains_Mono({
variable: "--font-jetbrains-mono",
subsets: ["latin"],
});
const openSans = Open_Sans({
variable: "--font-open-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "TuxMate - LINUX BULK APP INSTALLER",
description: "TuxMate helps you generate terminal commands to install your favorite apps on any Linux distribution. Select your distro, pick your apps, and get your install command.",
@@ -67,7 +78,7 @@ export default function RootLayout({
)}
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} ${jetbrainsMono.variable} ${openSans.variable} antialiased`}
>
<ThemeProvider>
{children}

View File

@@ -5,7 +5,7 @@ import gsap from 'gsap';
// Hooks
import { useLinuxInit } from '@/hooks/useLinuxInit';
import { useDelayedTooltip } from '@/hooks/useDelayedTooltip';
import { useTooltip } from '@/hooks/useTooltip';
import { useKeyboardNavigation, type NavItem } from '@/hooks/useKeyboardNavigation';
// Data
@@ -24,7 +24,7 @@ import { Tooltip, GlobalStyles, LoadingSkeleton } from '@/components/common';
export default function Home() {
// All the state we need to make this thing work
const { tooltip, show: showTooltip, hide: hideTooltip, onTooltipEnter, onTooltipLeave } = useDelayedTooltip(600);
const { tooltip, show: showTooltip, hide: hideTooltip, tooltipMouseEnter, tooltipMouseLeave } = useTooltip();
const {
selectedDistro,
@@ -89,15 +89,17 @@ export default function Home() {
// 5 columns looks good on most screens
const COLUMN_COUNT = 5;
// Tetris-style packing: shortest column gets the next category
// Pack categories into shortest column while preserving order
const columns = useMemo(() => {
const cols: Array<typeof allCategoriesWithApps> = Array.from({ length: COLUMN_COUNT }, () => []);
const heights = Array(COLUMN_COUNT).fill(0);
allCategoriesWithApps.forEach(catData => {
const minIdx = heights.indexOf(Math.min(...heights));
cols[minIdx].push(catData);
heights[minIdx] += catData.apps.length + 2;
});
return cols;
}, [allCategoriesWithApps]);
@@ -110,7 +112,11 @@ export default function Home() {
const toggleCategoryExpanded = useCallback((cat: string) => {
setExpandedCategories(prev => {
const next = new Set(prev);
next.has(cat) ? next.delete(cat) : next.add(cat);
if (next.has(cat)) {
next.delete(cat);
} else {
next.add(cat);
}
return next;
});
}, []);
@@ -132,7 +138,7 @@ export default function Home() {
return items;
}, [columns, expandedCategories]);
const { focusedItem, clearFocus, setFocusByItem } = useKeyboardNavigation(
const { focusedItem, clearFocus, setFocusByItem, isKeyboardNavigating } = useKeyboardNavigation(
navItems,
toggleCategoryExpanded,
toggleApp
@@ -197,7 +203,7 @@ export default function Home() {
onClick={clearFocus}
>
<GlobalStyles />
<Tooltip tooltip={tooltip} onEnter={onTooltipEnter} onLeave={onTooltipLeave} />
<Tooltip tooltip={tooltip} onMouseEnter={tooltipMouseEnter} onMouseLeave={tooltipMouseLeave} />
{/* Header */}
<header ref={headerRef} className="pt-8 sm:pt-12 pb-8 sm:pb-10 px-4 sm:px-6 relative" style={{ zIndex: 1 }}>
@@ -206,6 +212,7 @@ export default function Home() {
{/* Logo & Title */}
<div className="header-animate">
<div className="flex items-center gap-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/tuxmate.png"
alt="TuxMate Logo"
@@ -256,7 +263,7 @@ export default function Home() {
{/* Mobile: 2-column grid with balanced distribution */}
<div className="grid grid-cols-2 gap-x-4 md:hidden items-start">
{(() => {
// Tetris packing for 2 columns on mobile
// Pack into 2 columns on mobile
const mobileColumns: Array<typeof allCategoriesWithApps> = [[], []];
const heights = [0, 0];
allCategoriesWithApps.forEach(catData => {
@@ -277,8 +284,8 @@ export default function Home() {
toggleApp={toggleApp}
isExpanded={expandedCategories.has(category)}
onToggleExpanded={() => toggleCategoryExpanded(category)}
focusedId={focusedItem?.id}
focusedType={focusedItem?.type}
focusedId={isKeyboardNavigating ? focusedItem?.id : undefined}
focusedType={isKeyboardNavigating ? focusedItem?.type : undefined}
onTooltipEnter={showTooltip}
onTooltipLeave={hideTooltip}
categoryIndex={catIdx}
@@ -316,8 +323,8 @@ export default function Home() {
toggleApp={toggleApp}
isExpanded={expandedCategories.has(category)}
onToggleExpanded={() => toggleCategoryExpanded(category)}
focusedId={focusedItem?.id}
focusedType={focusedItem?.type}
focusedId={isKeyboardNavigating ? focusedItem?.id : undefined}
focusedType={isKeyboardNavigating ? focusedItem?.type : undefined}
onTooltipEnter={showTooltip}
onTooltipLeave={hideTooltip}
categoryIndex={globalIdx + catIdx}

View File

@@ -8,7 +8,7 @@ export function AppIcon({ url, name }: { url: string; name: string }) {
if (error) {
return (
<div className="w-4 h-4 rounded bg-[var(--accent)] flex items-center justify-center text-[10px] font-bold">
<div className="w-[18px] h-[18px] rounded bg-[var(--accent)] flex items-center justify-center text-[10px] font-bold">
{name[0]}
</div>
);
@@ -22,7 +22,7 @@ export function AppIcon({ url, name }: { url: string; name: string }) {
aria-hidden="true"
width={16}
height={16}
className="w-4 h-4 object-contain opacity-75"
className="w-[18px] h-[18px] object-contain opacity-75"
onError={() => setError(true)}
loading="lazy"
/>

View File

@@ -66,13 +66,6 @@ export const AppItem = memo(function AppItem({
}
}
}}
onMouseEnter={(e) => {
// Show description tooltip for all apps (available and unavailable)
onTooltipEnter(app.description, e);
}}
onMouseLeave={() => {
onTooltipLeave();
}}
>
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors duration-150
${isAur
@@ -85,12 +78,22 @@ export const AppItem = memo(function AppItem({
<AppIcon url={app.iconUrl} name={app.name} />
<div className="flex-1 flex items-baseline gap-1.5 min-w-0 overflow-hidden">
<span
className={`text-sm truncate ${!isAvailable ? 'text-[var(--text-muted)]' : isSelected ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}
className={`truncate cursor-help ${!isAvailable ? 'text-[var(--text-muted)]' : isSelected ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}
style={{
fontFamily: 'var(--font-open-sans), sans-serif',
fontSize: '16px',
transition: 'color 0.5s',
textRendering: 'geometricPrecision',
WebkitFontSmoothing: 'antialiased'
}}
onMouseEnter={(e) => {
e.stopPropagation();
onTooltipEnter(app.description, e);
}}
onMouseLeave={(e) => {
e.stopPropagation();
onTooltipLeave();
}}
>
{app.name}
</span>

View File

@@ -48,9 +48,9 @@ export function CategoryHeader({
aria-expanded={isExpanded}
aria-label={`${category} category, ${selectedCount} apps selected`}
// Soft Pill design: rounded tags with accent color
className={`category-header group w-full flex items-center gap-2 text-xs font-bold text-[var(--accent)]
className={`category-header group w-full h-7 flex items-center gap-2 text-xs font-bold text-[var(--accent)]
bg-[var(--accent)]/10 hover:bg-[var(--accent)]/20
uppercase tracking-wider py-1 px-3 rounded-lg mb-3
uppercase tracking-wider px-3 rounded-lg mb-3
transition-all duration-200 outline-none
${isFocused ? 'bg-[var(--accent)]/25 shadow-sm' : ''}`}
>
@@ -62,10 +62,10 @@ export function CategoryHeader({
<span className="flex-1 text-left">{category}</span>
{selectedCount > 0 && (
<span
className="text-[10px] bg-[var(--accent)]/10 text-[var(--accent)] w-5 h-5 rounded-full flex items-center justify-center font-bold"
style={{ transition: 'background-color 0.5s, color 0.5s' }}
className="text-xs text-[var(--accent)] font-bold ml-1.5 opacity-100"
style={{ transition: 'color 0.5s' }}
>
{selectedCount}
[{selectedCount}]
</span>
)}
</button>

View File

@@ -181,7 +181,13 @@ export function CommandDrawer({
<div className="p-4 font-mono text-sm overflow-x-auto">
<div className="flex gap-2">
<span className="text-emerald-400 select-none shrink-0">$</span>
<code className="text-gray-300 break-all whitespace-pre-wrap" style={{ lineHeight: '1.6' }}>
<code
className="text-gray-300 break-all whitespace-pre-wrap"
style={{
lineHeight: '1.6',
fontFamily: 'var(--font-jetbrains-mono), monospace'
}}
>
{command}
</code>
</div>

View File

@@ -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

View File

@@ -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<TooltipState | null>(null);
const [visible, setVisible] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(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 (
<strong key={i} className="font-bold text-[var(--accent)]">
{part.slice(2, -2)}
</strong>
);
return <strong key={i} className="font-medium text-[var(--text-primary)]">{part.slice(2, -2)}</strong>;
}
// Code
if (part.startsWith('`') && part.endsWith('`')) {
return (
<code key={i} className="bg-[var(--bg-secondary)] px-1 rounded font-mono text-[var(--accent)] text-[10px]">
{part.slice(1, -1)}
</code>
);
return <code key={i} className="px-1 py-0.5 rounded bg-black/20 font-mono text-[var(--accent)] text-[11px]">{part.slice(1, -1)}</code>;
}
// Link
if (part.startsWith('[') && part.includes('](') && part.endsWith(')')) {
const match = part.match(/\[(.*?)\]\((.*?)\)/);
if (match) {
return (
<a
key={i}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent)] underline decoration-[var(--accent)]/50 hover:decoration-[var(--accent)] font-semibold transition-colors hover:text-emerald-500"
onClick={(e) => e.stopPropagation()} // Prevent triggering parent clicks
>
<a key={i} href={match[2]} target="_blank" rel="noopener noreferrer"
className="text-[var(--accent)] underline underline-offset-2 hover:brightness-125 transition-all"
onClick={(e) => e.stopPropagation()}>
{match[1]}
</a>
);
@@ -66,30 +60,45 @@ export function Tooltip({ tooltip, onEnter, onLeave }: TooltipProps) {
});
};
// Hide tooltips on mobile - they don't work with touch
return (
<div
role="tooltip"
className="hidden md:block fixed z-50 pointer-events-auto"
style={{
left: left,
top: top,
transform: 'translate(-50%, -100%)',
// Using the specific key ensures fresh animation on new tooltip
animation: 'tooltipSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards',
maxWidth: '400px', // Limit width
width: 'max-content'
}}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
className="fixed hidden md:block pointer-events-auto z-[9999]"
style={{ left: current.x, top: current.y - 10 }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div className="relative mb-2 px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-primary)] text-xs font-medium shadow-xl border border-[var(--border-primary)]/40 backdrop-blur-sm whitespace-normal break-words leading-relaxed">
{renderContent(tooltip.text)}
{/* Arrow pointer */}
<div className={`
absolute left-1/2 bottom-0 -translate-x-1/2
transition-opacity duration-75
${visible ? 'opacity-100' : 'opacity-0 pointer-events-none'}
`}>
{/* Clean tooltip bubble */}
<div
className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-[var(--bg-tertiary)] border-b border-r border-[var(--border-primary)]/40 rotate-45"
/>
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)',
}}
>
<p className="text-[13px] leading-[1.55] text-[var(--text-secondary)] break-words" style={{ wordBreak: 'break-word' }}>
{renderContent(current.content)}
</p>
</div>
{/* Arrow */}
<div className="absolute left-1/2 -translate-x-1/2 -bottom-[5px]">
<div
className="w-2.5 h-2.5 rotate-45"
style={{
backgroundColor: 'var(--bg-secondary)',
borderRight: '1px solid var(--border-primary)',
borderBottom: '1px solid var(--border-primary)',
}}
/>
</div>
</div>
</div>
);

View File

@@ -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';

View File

@@ -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<FocusPosition | null>(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<HTMLElement>(
// Find visible element among duplicates (mobile/desktop layouts both render same data-nav-id)
const elements = document.querySelectorAll<HTMLElement>(
`[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,
};
}

101
src/hooks/useTooltip.ts Normal file
View File

@@ -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<TooltipState | null>(null);
const showTimeout = useRef<NodeJS.Timeout | null>(null);
const hideTimeout = useRef<NodeJS.Timeout | null>(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 };
}

View File

@@ -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