--- name: desktop-principles description: "Desktop-specific UX principles - hover states, pointer precision, keyboard shortcuts, multi-window, focus management. Covers macOS, Windows, Linux, web desktop." --- > **Version-sensitive.** Every API name, SDK gate and browser-support claim below was > verified on **2026-09-08** against primary sources. What against, and when, is in > `_jutsu/VERSIONS.md`. If that date is old, re-verify before acting on a version number. # Desktop Principles > Desktop UX context. Loaded when desktop is detected (macOS, Windows, Linux desktop, web desktop). > Concise rules here. Deep-dive in `references/`. --- ## Hover States Are Mandatory Hover is the primary affordance signal on desktop, the inverse of mobile. A pointer hovering over a target without immediate visual feedback feels broken: users rely on `:hover` to confirm an element is interactive before committing to a click. Every clickable surface must have a distinct hover style, ideally with a 100-200ms transition so the change is perceptible without feeling sluggish. **CSS - hover styles for interactive elements:** ```css .button { background: var(--surface); transition: background 120ms ease-out, transform 120ms ease-out; } .button:hover { background: var(--surface-hover); transform: translateY(-1px); } .button:active { transform: translateY(0); } ``` **SwiftUI - `.onHover` is the portable hover signal; `.hoverEffect` is iOS/iPadOS-only and must be `#if`-gated:** | Modifier | Availability | Notes | |---|---|---| | `.onHover { Bool }` | iOS 13.4+, iPadOS 13.4+, Mac Catalyst 13.4+, macOS 10.15+, visionOS 1.0+ | the one that works on macOS | | `.onContinuousHover { HoverPhase }` | iOS 17.0+, macOS 14.0+, tvOS 17.0+, visionOS 1.0+ | pointer position, not just in/out | | `.hoverEffect(_:)` | iOS 13.4+, iPadOS 13.4+, Mac Catalyst 13.4+, tvOS 16.0+, visionOS 1.0+ - **macOS unavailable** | compile error in a macOS target | | `.pointerStyle(_:)` | macOS 15.0+, visionOS 2.0+ | change the macOS cursor over a view | ```swift struct ToolbarButton: View { @State private var hovering = false var body: some View { Image(systemName: "square.and.arrow.up") .padding(8) .background(hovering ? Color.gray.opacity(0.15) : .clear) .onHover { hovering = $0 } .animation(.easeOut(duration: 0.12), value: hovering) .pointerHighlight() // see the extension below - .hoverEffect is UNAVAILABLE on macOS } } // `.hoverEffect` is @available(macOS, unavailable). Gate it with #if, never with #available. extension View { @ViewBuilder func pointerHighlight() -> some View { #if os(macOS) self #else self.hoverEffect(.highlight) #endif } } ``` **Compose Desktop - onPointerEvent or hoverable + interactionSource:** ```kotlin @OptIn(ExperimentalComposeUiApi::class) @Composable fun ToolbarButton(onClick: () -> Unit) { val interactionSource = remember { MutableInteractionSource() } val hovered by interactionSource.collectIsHoveredAsState() Box( modifier = Modifier .hoverable(interactionSource) .background(if (hovered) Color.LightGray.copy(alpha = 0.15f) else Color.Transparent) .clickable(onClick = onClick) .padding(8.dp), ) { Icon(Icons.Default.Share, contentDescription = "Share") } } ``` --- ## Pointer Precision Mouse and trackpad pointers are far more accurate than thumbs, so desktop targets can be smaller than the 44pt mobile minimum. Common ranges are 24-32px for icon buttons, 28-36px for toolbar items. WCAG 2.5.8 Target Size (Minimum), Level AA, sets the floor at **24x24 CSS pixels** for *all* pointer input - it is not desktop-specific. A sub-24px target still passes under the spacing exception: draw a 24px-diameter circle centred on each undersized target and no two circles may intersect. Apple's macOS HIG is stricter in spirit and looser in numbers: 28x28 pt default control size, 20x20 pt absolute minimum. **Fitts's Law in practice:** the time to acquire a target shrinks with size and grows with distance. Screen edges and corners are infinite-depth targets - the cursor stops there regardless of overshoot. Put high-frequency global controls (close window, system menu, app dock) in corners and along edges. macOS menubar and Windows taskbar are textbook applications: edge-anchored, zero-overshoot acquisition. --- ## Keyboard Shortcuts (first-class) Desktop users expect parity with native conventions. Missing `⌘+F` in a list-heavy app is not minimalism, it is a bug. | Action | macOS | Windows / Linux | |---|---|---| | New | `⌘+N` | `Ctrl+N` | | Close window | `⌘+W` | `Ctrl+W` | | Quit app | `⌘+Q` | `Alt+F4` | | Settings / Preferences | `⌘+,` | `Ctrl+,` | | Find | `⌘+F` | `Ctrl+F` | | Toggle (comment, sidebar...) | `⌘+/` | `Ctrl+/` | | Save | `⌘+S` | `Ctrl+S` | | Command palette | `⌘+K` or `⌘+Shift+P` | `Ctrl+K` or `Ctrl+Shift+P` | **Web - detect Ctrl vs Cmd correctly:** ```js // Prefer event.metaKey on macOS, event.ctrlKey elsewhere. // navigator.platform is deprecated but still pragmatic; fall back to userAgent. // navigator.platform is always a non-empty string where it exists, so `||` never falls through. // Prefer UA-CH where available, then platform, then the UA string. const isMac = /Mac|iPhone|iPad/.test( navigator.userAgentData?.platform ?? navigator.platform ?? navigator.userAgent ?? "" ); window.addEventListener("keydown", (e) => { const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey; if (cmdOrCtrl && e.key.toLowerCase() === "k") { e.preventDefault(); openCommandPalette(); } }); ``` **SwiftUI - .keyboardShortcut binds to menu commands:** ```swift Button("New Document", action: newDoc) .keyboardShortcut("n", modifiers: .command) Button("Find", action: focusSearch) .keyboardShortcut("f", modifiers: .command) ``` **Compose Desktop - onKeyEvent + KeyShortcut:** ```kotlin @OptIn(ExperimentalComposeUiApi::class) fun Modifier.commandShortcut(key: Key, onTrigger: () -> Unit) = onKeyEvent { event -> if (event.type == KeyEventType.KeyDown && event.isMetaPressed && event.key == key) { onTrigger(); true } else false } // In MenuBar: MenuBar { Menu("File") { Item("New", shortcut = KeyShortcut(Key.N, meta = true), onClick = ::newDoc) Item("Find", shortcut = KeyShortcut(Key.F, meta = true), onClick = ::focusSearch) } } ``` --- ## Multi-Window Patterns Desktop users keep windows side by side. A new window is the right answer when: - A task runs long enough that the user wants to keep working in the main window (rendering, export, sync log). - The user is comparing two parallel contexts (two documents, two chats, two issues). - The app is document-based and each document is a peer (Pages, Figma files, Xcode projects). A new window is the wrong answer for transient confirmations, brief settings panels, or anything that can live in a sheet or popover. **SwiftUI - WindowGroup for document-style, Window for singletons:** ```swift @main struct MyApp: App { var body: some Scene { WindowGroup("Document") { DocumentView() } // peer windows, one per doc Window("Inspector", id: "inspector") { InspectorView() } .windowResizability(.contentSize) // tracks intrinsic content size Settings { SettingsView() } // ⌘+, target on macOS } } ``` **Compose Desktop - Window composables, application scope:** ```kotlin fun main() = application { val docs = remember { mutableStateListOf(Document()) } docs.forEach { doc -> Window(onCloseRequest = { docs.remove(doc) }, title = doc.title) { DocumentView(doc) } } if (showInspector) { Window(onCloseRequest = { showInspector = false }, title = "Inspector") { InspectorView() } } } ``` **State sharing:** windows are *views* over the same model. Hold the source of truth in a singleton or a DI-scoped object (SwiftUI `@Observable` injected via environment, Compose `koin` or `viewModel`-equivalent). Never duplicate state per window - reconciling diverging copies is a graveyard of bugs. --- ## Focus Management Keyboard navigation is a first-class input on desktop. Tab order must be sane, focus rings must be visible, and removing them without an alternative is an accessibility regression. **SwiftUI - @FocusState drives field focus:** ```swift struct LoginForm: View { enum Field { case email, password } @State private var email = "" @State private var password = "" @FocusState private var focused: Field? var body: some View { VStack { TextField("Email", text: $email) .focused($focused, equals: .email) .onSubmit { focused = .password } SecureField("Password", text: $password) .focused($focused, equals: .password) .onSubmit(submit) } .onAppear { focused = .email } } } ``` **Compose - FocusRequester + LocalFocusManager:** ```kotlin val emailFocus = remember { FocusRequester() } val passwordFocus = remember { FocusRequester() } val focusManager = LocalFocusManager.current TextField( value = email, onValueChange = { email = it }, modifier = Modifier.focusRequester(emailFocus), // Without keyboardOptions.imeAction the IME never emits Next and onNext never fires. keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }), ) TextField( value = password, onValueChange = { password = it }, modifier = Modifier.focusRequester(passwordFocus).focusable(), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); submit() }), ) LaunchedEffect(Unit) { emailFocus.requestFocus() } ``` **Web - tabindex + :focus-visible:** ```css .button { /* Never `outline: none` without an alternative. */ } .button:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } ``` ```html
``` --- ## Information Density Desktop users sit on a 13-32 inch screen with a precise pointer and full keyboard. They can - and want to - parse more information per viewport than on mobile. Use an 8px base grid (vs 4-8px mobile), persistent sidebars instead of bottom tabs, command palettes (`⌘K`) for power users, and dense data tables when the data warrants it. Linear, Things 3, and Notion are the touchstones: information-rich without feeling cramped, every pixel earns its keep. --- ## Subtle Animations Doctrine Desktop apps are stared at for hours. Animations that feel delightful once become unbearable on the hundredth repetition. Prefer short, purely functional motion: opacity and small translations under 200ms, no bounces on routine interactions, no playful overshoots on hover. Save expressive motion for one-shot moments (onboarding, success states), never daily UI. ```css /* BAD - every hover bounces for 600ms, exhausting after the third use */ .card { transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1); } .card:hover { transform: scale(1.05); } ``` ```css /* GOOD - 100ms opacity, almost subliminal, never tires */ .card { opacity: 0.92; transition: opacity 100ms ease-out; } .card:hover { opacity: 1; } ``` --- ## Anti-Patterns (BAD / GOOD) ### 1. Hiding navigation behind a hamburger on desktop ```html