Skip to main content
ClaudeWave
Skill1.2k repo starsupdated 3d ago

desktop-principles

Desktop-specific UX principles - hover states, pointer precision, keyboard shortcuts, multi-window, focus management. Covers macOS, Windows, Linux, web desktop.

Install in Claude Code
Copy
git clone --depth 1 https://github.com/Jwuthri/Tracely-ai /tmp/desktop-principles && cp -r /tmp/desktop-principles/.agents/skills/desktop-principles ~/.claude/skills/desktop-principles
Then start a new Claude Code session; the skill loads automatically.

SKILL.md

# 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 for macOS, .hoverEffect for iPadOS:**
```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)
      .hoverEffect(.highlight) // iPadOS pointer support, no-op on macOS
  }
}
```

**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 (AA, target size minimum) sets the absolute floor at **24x24 CSS pixels** for non-mobile pointer input. Sub-24px targets need spacing or be grouped with sibling targets.

**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.
const isMac = /Mac|iPhone|iPad/.test(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.