fix: service worker network-first strategy + lint fixes

This commit is contained in:
N1C4T
2026-01-10 19:50:59 +04:00
parent 1a55696407
commit a5e82fffa6
21 changed files with 136 additions and 240 deletions

View File

@@ -1,164 +0,0 @@
# AccessGuide.io - Accessibility Guidelines Reference
*Compiled from [Access Guide](https://www.accessguide.io/) - a friendly introduction to digital accessibility based on WCAG 2.1*
---
## 1. Hover and Focus Best Practices
> WCAG criterion [1.4.13 Content on Hover or Focus](https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html) (Level AA)
### Why This is Important
If content appears and disappears on hover or focus, this can feel frustrating, unpredictable, and disruptive. Use best practices to make hover and focus more predictable and less likely to cause errors.
This is especially accessible for:
- People with **physical disabilities** (unpredictable or specific movement)
- People with **visual disabilities** (screen reader users)
- People with **cognitive disabilities**
### Implementation Guidelines
#### Dismissible
There should be a way to dismiss new content **without moving hover or changing focus**. This prevents disrupting users in the middle of tasks.
**Best practice:** Use the "Escape" key to dismiss content.
#### Hoverable
New content should **remain visible if the user hovers over it**.
Sometimes content appears when hovering the trigger element but disappears when hovering over the new content. This is frustrating. The content must remain visible whether hover is on the trigger OR the content itself.
#### Persistent
Content must remain visible until:
- The user dismisses it
- The user moves the mouse off of it or the trigger
- The content no longer contains important information
---
## 2. Keyboard Accessibility
> WCAG criterion [2.1.1 Keyboard](https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html) (Level A)
### Why This is Important
Full keyboard functionality is essential for:
- People who rely on keyboards
- Blind/visually impaired people using screen readers
- People with motor disabilities who can't use a mouse
**All functionality available to mouse users should be available to keyboard users.**
### Basic Keyboard Access
Keyboard accessibility is enabled by default in browsers. **Don't remove or deactivate these defaults** - enhance them instead.
Keyboard accessibility includes:
- ✅ Visible focus indicator
- ✅ Intuitive focus order
- ✅ Skip links to bypass repeating content
- ✅ Way to turn off character key shortcuts
- ✅ No keyboard traps
**Common keyboard-accessible interactions:**
- Browsing navigation
- Filling out forms
- Accessing buttons and links
### Advanced Keyboard Access
For complex interactions (drawing programs, drag-and-drop):
- Translate gestures into keyboard commands
- Use **Tab, Enter, Space, and Arrow keys** (most common)
**Examples from Salesforce:**
- Interact with a canvas (move/resize objects)
- Move between lists
- Sort lists
---
## 3. Focus Indicator Visibility
> WCAG criterion [2.4.7 Focus Visible](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html) (Level AA)
### Why This is Important
A visible focus indicator shows keyboard users what element they're currently interacting with.
Benefits:
- Shows what element is ready for user input
- Helps people with executive dysfunction
- Reduces cognitive load by focusing attention
### Implementation Guidelines
#### Never Remove Default Focus Indicator
```css
/* ❌ NEVER DO THIS unless replacing with custom styling */
outline: none;
```
#### Design Accessible Hover and Focus States
**All interactive elements need hover AND focus states:**
- Buttons, links
- Text fields
- Navigation elements
- Radio buttons, checkboxes
**Difference between hover and focus:**
| State | Purpose |
|-------|---------|
| Hover | "You could interact with this" |
| Focus | "You ARE interacting with this right now" |
**Other important states:**
- Error state
- Loading state
- Inactive/disabled state
#### Focus Indicator Contrast
The focus indicator must be visible against **both**:
- The element itself
- The background behind it
**WCAG requirement:** 3:1 contrast ratio for UI components
---
## 4. Key Principles Summary
### For TuxMate Application
| Principle | Application |
|-----------|-------------|
| **Dismissible** | Tooltips close with Escape key |
| **Hoverable** | Tooltips stay visible when hovering content |
| **Persistent** | Content stays until user dismisses |
| **Keyboard** | All features work with Tab/Arrow/Enter/Space |
| **Focus Visible** | Clear visual indicator on focused elements |
| **Contrast** | 3:1 minimum for UI components |
### Recommended Key Bindings
| Key | Common Action |
|-----|---------------|
| `Tab` | Move to next focusable element |
| `Shift+Tab` | Move to previous element |
| `Enter/Space` | Activate/select |
| `Arrow keys` | Navigate within components |
| `Escape` | Dismiss/close |
| `?` | Show help |
---
## Further Reading
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [MDN Web Docs - Keyboard](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Understanding_WCAG/Keyboard)
- [18F - Keyboard Access](https://accessibility.18f.gov/keyboard/)
- [Style hover, focus, and active states differently](https://zellwk.com/blog/style-hover-focus-active-states/)
- [Accessible Custom Focus Indicators](https://uxdesign.cc/accessible-custom-focus-indicators-da4768d1fb7b)

View File

@@ -1,16 +1,14 @@
// TuxMate Service Worker // TuxMate Service Worker
// caches the entire static app for offline use - perfect for those fresh installs with spotty wifi // caches the app for offline use - network-first so you always get fresh styles
const CACHE_NAME = 'tuxmate-v1'; const CACHE_NAME = 'tuxmate-v2';
// install: we'll cache on-demand instead of precaching // install: skip waiting so updates apply immediately
// Next.js dev mode doesn't have static files where we expect them
self.addEventListener('install', () => { self.addEventListener('install', () => {
// skipWaiting so updates apply immediately
self.skipWaiting(); self.skipWaiting();
}); });
// activate: clean up old caches when we update // activate: clean up old caches
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((cacheNames) => { caches.keys().then((cacheNames) => {
@@ -20,48 +18,34 @@ self.addEventListener('activate', (event) => {
.map((name) => caches.delete(name)) .map((name) => caches.delete(name))
); );
}).then(() => { }).then(() => {
// take control of all clients immediately
return self.clients.claim(); return self.clients.claim();
}) })
); );
}); });
// fetch: cache-first with network fallback // fetch: network-first with cache fallback (for offline support)
// cache everything we successfully fetch for offline use
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
// only handle GET requests
if (event.request.method !== 'GET') return; if (event.request.method !== 'GET') return;
// skip chrome-extension, analytics, and other non-http requests
const url = new URL(event.request.url); const url = new URL(event.request.url);
if (!url.protocol.startsWith('http')) return; if (!url.protocol.startsWith('http')) return;
// skip external requests (analytics, fonts CDN, etc.) - only cache our stuff
if (url.origin !== self.location.origin) return; if (url.origin !== self.location.origin) return;
event.respondWith( event.respondWith(
caches.match(event.request).then((cachedResponse) => { fetch(event.request)
if (cachedResponse) { .then((response) => {
// got it cached, serve it up // got fresh response, cache it for offline
return cachedResponse; if (response && response.status === 200 && response.type === 'basic') {
} const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
// not cached, fetch and cache it for next time cache.put(event.request, responseToCache);
return fetch(event.request).then((response) => { });
// don't cache non-ok responses
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
} }
// clone because response can only be consumed once
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response; return response;
}); })
}) .catch(() => {
// network failed, try cache (offline mode)
return caches.match(event.request);
})
); );
}); });

View File

@@ -37,7 +37,11 @@ export const metadata: Metadata = {
}, },
}; };
// Script to run before React hydrates to prevent theme flash /**
* Inline script that runs before React hydrates.
* Sets the theme immediately to prevent that jarring flash when
* you visit the page at 2am and get blinded by light mode.
*/
const themeScript = ` const themeScript = `
(function() { (function() {
try { try {

View File

@@ -100,8 +100,7 @@ export default function Home() {
return cols; return cols;
}, [allCategoriesWithApps]); }, [allCategoriesWithApps]);
// ======================================================================== // Category expansion - all open by default because hiding stuff is annoying
// Category Expansion State
// ======================================================================== // ========================================================================
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(() => new Set(categories)); const [expandedCategories, setExpandedCategories] = useState<Set<string>>(() => new Set(categories));
@@ -141,8 +140,7 @@ export default function Home() {
toggleApp toggleApp
); );
// ======================================================================== // Header animation - makes the logo look fancy on first load
// Header Animation
// ======================================================================== // ========================================================================
const headerRef = useRef<HTMLElement>(null); const headerRef = useRef<HTMLElement>(null);
@@ -189,8 +187,7 @@ export default function Home() {
return <LoadingSkeleton />; return <LoadingSkeleton />;
} }
// ======================================================================== // Finally, the actual page
// Render
// ======================================================================== // ========================================================================
return ( return (

View File

@@ -3,13 +3,16 @@
import { memo } from 'react'; import { memo } from 'react';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import { distros, type DistroId, type AppData } from '@/lib/data'; import { distros, type DistroId, type AppData } from '@/lib/data';
import { analytics } from '@/lib/analytics';
import { isAurPackage } from '@/lib/aur'; import { isAurPackage } from '@/lib/aur';
import { AppIcon } from './AppIcon'; import { AppIcon } from './AppIcon';
// Each app row in the list. Memoized because there are a LOT of these. /**
* Individual app row in the category list.
* Memoized because we render hundreds of these and React was having a moment.
* Handles selection state, availability indicators, AUR badges, and tooltips.
*/
// Basic Tailwind-ish color palette for mapping // Tailwind colors as hex - because CSS variables don't work in inline styles
const COLOR_MAP: Record<string, string> = { const COLOR_MAP: Record<string, string> = {
'orange': '#f97316', 'orange': '#f97316',
'blue': '#3b82f6', 'blue': '#3b82f6',
@@ -62,8 +65,7 @@ export const AppItem = memo(function AppItem({
// Special styling for AUR packages (Arch users love their badges) // Special styling for AUR packages (Arch users love their badges)
const isAur = selectedDistro === 'arch' && app.targets?.arch && isAurPackage(app.targets.arch); const isAur = selectedDistro === 'arch' && app.targets?.arch && isAurPackage(app.targets.arch);
// Determine effective color (AUR overrides category color for the checkbox/badge, but maybe not the row border) // AUR gets its special Arch blue, everything else uses category color
// Actually, let's keep the row border as the category color for consistency
const hexColor = COLOR_MAP[color] || COLOR_MAP['gray']; const hexColor = COLOR_MAP[color] || COLOR_MAP['gray'];
const checkboxColor = isAur ? '#1793d1' : hexColor; const checkboxColor = isAur ? '#1793d1' : hexColor;
@@ -90,7 +92,6 @@ export const AppItem = memo(function AppItem({
e.stopPropagation(); e.stopPropagation();
onFocus?.(); onFocus?.();
if (isAvailable) { if (isAvailable) {
const willBeSelected = !isSelected;
onToggle(); onToggle();
} }
}} }}

View File

@@ -6,6 +6,7 @@ import {
Network, Lock, Share2, Cpu, type LucideIcon Network, Lock, Share2, Cpu, type LucideIcon
} from 'lucide-react'; } from 'lucide-react';
// Map category names to their icons. If you add a category, add an icon here.
const CATEGORY_ICONS: Record<string, LucideIcon> = { const CATEGORY_ICONS: Record<string, LucideIcon> = {
'Web Browsers': Globe, 'Web Browsers': Globe,
'Communication': MessageCircle, 'Communication': MessageCircle,
@@ -24,7 +25,7 @@ const CATEGORY_ICONS: Record<string, LucideIcon> = {
'System': Cpu, 'System': Cpu,
}; };
// Basic Tailwind-ish color palette for mapping // Tailwind colors as hex
const COLOR_MAP: Record<string, string> = { const COLOR_MAP: Record<string, string> = {
'orange': '#f97316', 'orange': '#f97316',
'blue': '#3b82f6', 'blue': '#3b82f6',
@@ -43,7 +44,10 @@ const COLOR_MAP: Record<string, string> = {
'gray': '#6b7280', 'gray': '#6b7280',
}; };
// Clickable category header with chevron and selection count /**
* Collapsible category header with icon, chevron, and selection badge.
* Uses color-mix for dynamic tinting because we're fancy like that.
*/
export function CategoryHeader({ export function CategoryHeader({
category, category,
isExpanded, isExpanded,

View File

@@ -7,7 +7,11 @@ import { analytics } from '@/lib/analytics';
import { CategoryHeader } from './CategoryHeader'; import { CategoryHeader } from './CategoryHeader';
import { AppItem } from './AppItem'; import { AppItem } from './AppItem';
// A category with its apps - handles animations and expand/collapse /**
* A collapsible category section containing a list of apps.
* Handles its own GSAP animations because CSS transitions just weren't cutting it.
* Memoized to hell and back because React was re-rendering everything.
*/
interface CategorySectionProps { interface CategorySectionProps {
category: Category; category: Category;
categoryApps: AppData[]; categoryApps: AppData[];
@@ -26,7 +30,10 @@ interface CategorySectionProps {
onAppFocus?: (appId: string) => void; onAppFocus?: (appId: string) => void;
} }
// Category color mapping /**
* Color palette for categories. Vibrant ones go to user-facing stuff,
* boring grays go to developer tools because we're used to suffering.
*/
const categoryColors: Record<Category, string> = { const categoryColors: Record<Category, string> = {
'Web Browsers': 'orange', 'Web Browsers': 'orange',
'Communication': 'blue', 'Communication': 'blue',
@@ -162,7 +169,10 @@ function CategorySectionComponent({
); );
} }
// Custom memo comparison to ensure proper re-renders when categoryApps changes /**
* Custom memo comparison because React's shallow compare was killing perf.
* This is the kind of thing that makes you question your career choices.
*/
export const CategorySection = memo(CategorySectionComponent, (prevProps, nextProps) => { export const CategorySection = memo(CategorySectionComponent, (prevProps, nextProps) => {
// Always re-render if app count changes // Always re-render if app count changes
if (prevProps.categoryApps.length !== nextProps.categoryApps.length) return false; if (prevProps.categoryApps.length !== nextProps.categoryApps.length) return false;

View File

@@ -8,7 +8,12 @@ interface AurDrawerSettingsProps {
setSelectedHelper: (helper: 'yay' | 'paru') => void; setSelectedHelper: (helper: 'yay' | 'paru') => void;
} }
// AUR settings configuration panel /**
* AUR package settings panel for Arch users.
* Lets you pick between yay and paru, and whether to install the helper.
* The naming of hasYayInstalled is a bit misleading - it actually means
* "user already has an AUR helper" regardless of which one. Tech debt, I know.
*/
export function AurDrawerSettings({ export function AurDrawerSettings({
aurAppNames, aurAppNames,
hasYayInstalled, hasYayInstalled,

View File

@@ -12,7 +12,11 @@ interface AurFloatingCardProps {
setSelectedHelper: (helper: 'yay' | 'paru') => void; setSelectedHelper: (helper: 'yay' | 'paru') => void;
} }
// Floating cards that ask Arch users about their AUR helper (yay vs paru drama) /**
* Floating card wizard for Arch users with AUR packages.
* Asks whether they have an AUR helper, then which one.
* The yay vs paru debate is the real holy war of our time.
*/
export function AurFloatingCard({ export function AurFloatingCard({
show, show,
aurAppNames, aurAppNames,
@@ -27,7 +31,7 @@ export function AurFloatingCard({
const [hasAnswered, setHasAnswered] = useState<boolean | null>(null); const [hasAnswered, setHasAnswered] = useState<boolean | null>(null);
// Track if user has selected a helper (completed flow) // Track if user has selected a helper (completed flow)
const [helperChosen, setHelperChosen] = useState(false); const [helperChosen, setHelperChosen] = useState(false);
// Track if user has interacted (dismissed or selected) to prevent nagging - use ref to persist // Tracks if user has answered - use ref to survive re-renders
const userInteractedRef = useRef(false); const userInteractedRef = useRef(false);
// Reset when new AUR packages appear, BUT ONLY if user hasn't interacted yet // Reset when new AUR packages appear, BUT ONLY if user hasn't interacted yet

View File

@@ -23,7 +23,11 @@ interface CommandDrawerProps {
distroColor: string; distroColor: string;
} }
// Bottom sheet for mobile, centered modal for desktop /**
* Command drawer that shows the generated install command.
* Acts as a bottom sheet on mobile (swipe to dismiss) and a centered modal on desktop.
* If you're reading this, yes, I did spend way too much time on the animations.
*/
export function CommandDrawer({ export function CommandDrawer({
isOpen, isOpen,
isClosing, isClosing,
@@ -41,11 +45,11 @@ export function CommandDrawer({
setSelectedHelper, setSelectedHelper,
distroColor, distroColor,
}: CommandDrawerProps) { }: CommandDrawerProps) {
// Swipe-to-dismiss state // Swipe-to-dismiss for mobile users who hate tapping tiny X buttons
const [dragOffset, setDragOffset] = useState(0); const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const dragStartY = useRef(0); const dragStartY = useRef(0);
const DISMISS_THRESHOLD = 100; // px to drag before closing const DISMISS_THRESHOLD = 100; // Feels right™
const handleTouchStart = (e: React.TouchEvent) => { const handleTouchStart = (e: React.TouchEvent) => {
dragStartY.current = e.touches[0].clientY; dragStartY.current = e.touches[0].clientY;
@@ -69,6 +73,7 @@ export function CommandDrawer({
if (!isOpen) return null; if (!isOpen) return null;
// Copy command and auto-close after a celebratory 3 seconds
const handleCopyAndClose = () => { const handleCopyAndClose = () => {
onCopy(); onCopy();
setTimeout(onClose, 3000); setTimeout(onClose, 3000);
@@ -149,8 +154,7 @@ export function CommandDrawer({
/> />
)} )}
{/* Terminal window */} {/* Terminal preview - where the magic gets displayed */}
{/* Terminal window */}
<div className="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] overflow-hidden shadow-sm"> <div className="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] overflow-hidden shadow-sm">
<div className="flex items-center justify-between px-4 py-2 bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)]"> <div className="flex items-center justify-between px-4 py-2 bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)]">
<span className="text-xs font-mono text-[var(--text-muted)]">bash</span> <span className="text-xs font-mono text-[var(--text-muted)]">bash</span>

View File

@@ -27,7 +27,11 @@ interface CommandFooterProps {
setSelectedHelper: (helper: 'yay' | 'paru') => void; setSelectedHelper: (helper: 'yay' | 'paru') => void;
} }
// The sticky footer with command preview and copy/download buttons /**
* The sticky footer that shows the generated command and action buttons.
* Contains more state than I'd like, but hey, it works.
* Keyboard shortcuts are vim-style because we're not savages.
*/
export function CommandFooter({ export function CommandFooter({
command, command,
selectedCount, selectedCount,
@@ -52,9 +56,11 @@ export function CommandFooter({
const { toggle: toggleTheme } = useTheme(); const { toggle: toggleTheme } = useTheme();
// Track if selection has changed from initial state (user interaction) // Track if user has actually interacted - we hide the bar until then.
// Otherwise it just sits there looking sad with "No apps selected".
useEffect(() => { useEffect(() => {
if (selectedCount !== initialCountRef.current && !hasEverHadSelection) { if (selectedCount !== initialCountRef.current && !hasEverHadSelection) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setHasEverHadSelection(true); setHasEverHadSelection(true);
} }
}, [selectedCount, hasEverHadSelection]); }, [selectedCount, hasEverHadSelection]);

View File

@@ -14,7 +14,11 @@ interface ShortcutsBarProps {
setSelectedHelper: (helper: 'yay' | 'paru') => void; setSelectedHelper: (helper: 'yay' | 'paru') => void;
} }
// Neovim-style statusline - looks cool and shows all the keyboard shortcuts /**
* Neovim-style statusline at the bottom.
* Shows search, app count, AUR helper toggle, and keyboard shortcuts.
* If you use vim, you'll feel right at home.
*/
export function ShortcutsBar({ export function ShortcutsBar({
searchQuery, searchQuery,
onSearchChange, onSearchChange,

View File

@@ -10,6 +10,11 @@ interface TooltipProps {
setRef?: (el: HTMLDivElement | null) => void; setRef?: (el: HTMLDivElement | null) => void;
} }
/**
* Follow-cursor tooltip that appears on hover.
* Desktop only - mobile users don't have a cursor to follow.
* Supports markdown-ish formatting: **bold**, `code`, and [links](url).
*/
export function Tooltip({ tooltip, onMouseEnter, onMouseLeave, setRef }: TooltipProps) { export function Tooltip({ tooltip, onMouseEnter, onMouseLeave, setRef }: TooltipProps) {
const [current, setCurrent] = useState<TooltipState | null>(null); const [current, setCurrent] = useState<TooltipState | null>(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);

View File

@@ -4,10 +4,12 @@ import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Check, ChevronDown } from 'lucide-react'; import { Check, ChevronDown } from 'lucide-react';
import { distros, type DistroId } from '@/lib/data'; import { distros, type DistroId } from '@/lib/data';
import { analytics } from '@/lib/analytics';
import { DistroIcon } from './DistroIcon'; import { DistroIcon } from './DistroIcon';
// Dropdown to pick your Linux flavor /**
* Distro picker dropdown. Uses portal rendering so the dropdown isn't
* clipped by parent overflow. Learned that lesson the hard way.
*/
export function DistroSelector({ export function DistroSelector({
selectedDistro, selectedDistro,
onSelect onSelect
@@ -40,7 +42,8 @@ export function DistroSelector({
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
// Dropdown rendered via portal to body // Portal the dropdown to body so it's not affected by parent styles.
// The positioning math looks scary but it's just "anchor to button bottom-right".
const dropdown = isOpen && mounted ? ( const dropdown = isOpen && mounted ? (
<> <>
{/* Backdrop with subtle blur */} {/* Backdrop with subtle blur */}

View File

@@ -5,7 +5,10 @@ import { createPortal } from 'react-dom';
import { HelpCircle, X } from 'lucide-react'; import { HelpCircle, X } from 'lucide-react';
import { analytics } from '@/lib/analytics'; import { analytics } from '@/lib/analytics';
// The "?" help modal - shows keyboard shortcuts and how to use the app /**
* Help modal with keyboard shortcuts and getting started guide.
* Opens with "?" key - because that's what you'd naturally press.
*/
export function HowItWorks() { export function HowItWorks() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);

View File

@@ -14,7 +14,11 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
const { theme, toggle } = useTheme() const { theme, toggle } = useTheme()
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
// Prevent hydration mismatch by only rendering after mount /**
* Classic hydration mismatch avoidance. Server has no idea what
* localStorage says, so we render a placeholder first.
* Yes, there's probably a better way. No, I don't want to hear about it.
*/
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true) setMounted(true)

View File

@@ -17,7 +17,11 @@ export interface FocusPosition {
} }
// Vim-style keyboard navigation. Because real devs don't use mice. /**
* Vim-style keyboard navigation for the app grid.
* Because hjkl is objectively superior to arrow keys.
* Also supports arrow keys for the normies.
*/
export function useKeyboardNavigation( export function useKeyboardNavigation(
navItems: NavItem[][], navItems: NavItem[][],
onToggleCategory: (id: string) => void, onToggleCategory: (id: string) => void,

View File

@@ -7,7 +7,11 @@ import { isAurPackage } from '@/lib/aur';
// Re-export for backwards compatibility // Re-export for backwards compatibility
export { isAurPackage, AUR_PATTERNS, KNOWN_AUR_PACKAGES } from '@/lib/aur'; export { isAurPackage, AUR_PATTERNS, KNOWN_AUR_PACKAGES } from '@/lib/aur';
// Everything the app needs to work /**
* The big hook that runs the whole show.
* Manages distro selection, app selection, localStorage persistence,
* and command generation. If this breaks, everything breaks.
*/
export interface UseLinuxInitReturn { export interface UseLinuxInitReturn {
selectedDistro: DistroId; selectedDistro: DistroId;
@@ -83,7 +87,7 @@ export function useLinuxInit(): UseLinuxInitReturn {
setHydrated(true); setHydrated(true);
}, []); }, []);
// Persist to localStorage when state changes // Save to localStorage whenever state changes (but not on first render)
useEffect(() => { useEffect(() => {
if (!hydrated) return; if (!hydrated) return;
try { try {
@@ -223,7 +227,7 @@ export function useLinuxInit(): UseLinuxInitReturn {
return packageNames.map(p => `sudo snap install ${p}`).join(' && '); return packageNames.map(p => `sudo snap install ${p}`).join(' && ');
} }
// Handle Arch Linux with AUR packages // Arch with AUR packages - this is where it gets fun
if (selectedDistro === 'arch' && aurPackageInfo.hasAur) { if (selectedDistro === 'arch' && aurPackageInfo.hasAur) {
if (!hasYayInstalled) { if (!hasYayInstalled) {
// User doesn't have current helper installed - prepend installation // User doesn't have current helper installed - prepend installation

View File

@@ -11,6 +11,10 @@ interface ThemeContextType {
const ThemeContext = createContext<ThemeContextType | undefined>(undefined) const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
/**
* Theme provider that syncs with localStorage and system preferences.
* Also handles the initial hydration dance to avoid theme flash.
*/
export function ThemeProvider({ children }: { children: React.ReactNode }) { export function ThemeProvider({ children }: { children: React.ReactNode }) {
// Initial state reads from DOM to match what the inline script set // Initial state reads from DOM to match what the inline script set
const [theme, setTheme] = useState<Theme>(() => { const [theme, setTheme] = useState<Theme>(() => {

View File

@@ -1,6 +1,10 @@
// AUR package detection - figures out if a package is from AUR or official repos /**
* AUR package detection for Arch users.
* Figures out if a package comes from AUR or official repos.
* Necessary because yay/paru handle AUR packages differently.
*/
/** Patterns that indicate an AUR package (suffixes) */ /** Suffixes that scream "I'm from the AUR" */
export const AUR_PATTERNS = ['-bin', '-git', '-appimage']; export const AUR_PATTERNS = ['-bin', '-git', '-appimage'];
/** /**

View File

@@ -21,7 +21,10 @@ interface ScriptOptions {
helper?: 'yay' | 'paru'; helper?: 'yay' | 'paru';
} }
// The full fancy script with progress bars and all that jazz /**
* Generate a full install script with progress bars, error handling,
* and all the fancy stuff. This is what gets downloaded as .sh file.
*/
export function generateInstallScript(options: ScriptOptions): string { export function generateInstallScript(options: ScriptOptions): string {
const { distroId, selectedAppIds, helper = 'yay' } = options; const { distroId, selectedAppIds, helper = 'yay' } = options;
const distro = distros.find(d => d.id === distroId); const distro = distros.find(d => d.id === distroId);
@@ -45,7 +48,10 @@ export function generateInstallScript(options: ScriptOptions): string {
} }
} }
// Quick one-liner for copy-paste warriors /**
* Quick one-liner for the clipboard. No frills, just the command.
* For users who know what they're doing and just want to paste.
*/
export function generateSimpleCommand(selectedAppIds: Set<string>, distroId: DistroId): string { export function generateSimpleCommand(selectedAppIds: Set<string>, distroId: DistroId): string {
const packages = getSelectedPackages(selectedAppIds, distroId); const packages = getSelectedPackages(selectedAppIds, distroId);
if (packages.length === 0) return '# No packages selected'; if (packages.length === 0) return '# No packages selected';