Skip to main content
skill CX Codex

desktop-principles — Claude Code skill: desktop UX rules

A skill with desktop UX rules: hover states, pointer precision, keyboard shortcuts, multi-window patterns. Useful when the interface targets macOS, Windows or Linux.

Install into Claude Code · Skills CLI (Vercel) All methods and platforms ↓
npx skills add AThevon/genjutsu --skill desktop-principles -a claude-code
Open on GitHub

What desktop-principles does

The skill loads when the agent detects a desktop environment and sets the rules for the interface. Hover is treated as mandatory: a pointer over a target with no visual feedback reads as broken, so every clickable surface needs its own hover style with a 100-200ms transition. For SwiftUI the skill calls out that .hoverEffect is unavailable on macOS and must be gated with #if rather than #available — otherwise a macOS target fails to compile.

Target sizes run smaller than mobile: 24-32px for icon buttons, 28-36px for toolbar items. The floor comes from WCAG 2.5.8 at 24x24 CSS pixels for any pointer input, which is not a desktop-only rule. High-frequency global controls belong in corners and along screen edges, where the cursor stops on its own.

Keyboard shortcuts get first-class treatment: a mapping table for macOS against Windows and Linux, examples in SwiftUI and Compose Desktop, plus metaKey versus ctrlKey detection on the web. A separate section covers multi-window patterns and when a sheet or popover does the job instead. Everything is marked version-sensitive: API names and browser-support claims were checked on 2026-09-08, so an old date is a reason to re-verify version numbers before relying on them.

What it helps with

  • add hover states to every clickable element
  • pick target sizes for mouse and trackpad input
  • split keyboard shortcuts between macOS and Windows
  • decide when a task needs a new window instead of a modal
  • check that .hoverEffect won't break a macOS build

Example prompts for the agent

  • review hover states and target sizes in this interface against desktop-principles
  • add keyboard shortcuts for find and the command palette using the skill's rules
  • should export open in its own window — check desktop-principles

How to install desktop-principles

1Skills CLI (Vercel)

npx skills add AThevon/genjutsu --skill desktop-principles -a claude-code

2Manually: put the files in place

~/.claude/skills/desktop-principles/SKILL.md

Copy the whole skill folder; for one project use .claude/skills/desktop-principles/

Step by step, with commands for each platform: How to install skills in Claude Code: five ways

Questions about desktop-principles

How do I install desktop-principles in Claude?

Run npx skills add AThevon/genjutsu --skill desktop-principles -a claude-code. The skill file then lives at ~/.claude/skills/desktop-principles/SKILL.md.

What does desktop-principles do?

It gives the agent desktop UX rules: hover states, pointer-sized targets, keyboard shortcuts, multi-window and focus handling. The skill loads once a desktop platform is detected.

Which platforms does the skill support?

Claude, Codex, Cursor, GitHub Copilot, Gemini CLI and OpenCode. Inside it covers macOS, Windows, Linux and web desktop.

Source text

SKILL.md · MIT
---
name: desktop-principles
description: "Desktop-specific UX principles - hover states, pointer precision, keyboard shortcuts, multi-window, focus management. Covers macOS, Windows, Linux, web desktop."
metadata:
  internal: true
---

> **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
<!-- tabindex="0" puts a non-interactive element in tab order -->
<div role="button" tabindex="0" class="button">Custom button</div>
```

---

## 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
<!-- BAD - 1440px viewport, infinite room, but nav is collapsed -->
<header>
  <button class="hamburger" aria-label="Menu">☰</button>
</header>
<nav class="drawer hidden">...</nav>
```

```html
<!-- GOOD - persistent sidebar on desktop, collapsible if the user wants it -->
<aside class="sidebar">
  <nav>
    <a href="/inbox">Inbox</a>
    <a href="/projects">Projects</a>
    <a href="/archive">Archive</a>
  </nav>
  <button class="collapse-toggle" aria-label="Collapse sidebar">⇤</button>
</aside>
```

### 2. No keyboard shortcuts for primary actions

```jsx
// BAD - "New" is buried in a menu, no shortcut, every creation is 3 clicks
<Toolbar>
  <Menu>
    <MenuItem onClick={newDoc}>New document</MenuItem>
  </Menu>
</Toolbar>
```

```jsx
// GOOD - ⌘N baseline, surfaced in the menu, mirrored on the toolbar tooltip
<Toolbar>
  <button onClick={newDoc} title="New document (⌘N)">
    <PlusIcon />
  </button>
</Toolbar>
// Bound globally:
useShortcut("mod+n", newDoc);
useShortcut("mod+s", save);
useShortcut("mod+f", focusSearch);
```

### 3. Removing focus rings without an alternative

```css
/* BAD - keyboard users now have no idea where focus is */
button:focus { outline: none; }
```

```css
/* GOOD - :focus-visible keeps mouse clicks ring-free, keyboard navigation visible */
button:focus { outline: none; }
button:focus-visible {
  outline: 2px solid var(--brand-500);
  outline-offset: 2px;
  border-radius: 6px;
}
```

---

## Quick Reference: Loading sub-skills

| Need | Load |
|---|---|
| Keyboard patterns deep-dive | `references/keyboard-patterns.md` |
| Multi-window state | `references/multi-window.md` |
| SwiftUI animations | `../swiftui-motion/SKILL.md` |
| Compose Desktop | `../compose-multiplatform/SKILL.md` (when desktop is a CMP target) |

---

## Sources

- Apple Human Interface Guidelines (macOS): https://developer.apple.com/design/human-interface-guidelines/macos
- Microsoft Fluent Design 2: https://fluent2.microsoft.design/
- GNOME Human Interface Guidelines: https://developer.gnome.org/hig/

© AThevon · MIT · AThevon/genjutsu

Similar skills

Everything for Frontend and UI →

From the same set17

css-native A skill that tells your coding agent when plain CSS is enough for animations and visual effects, and when to reach for a library. CX Codex +1 1,100installs gsap This skill helps an agent write GSAP animations with timelines, staggered effects, and ScrollTrigger. Use it for complex motion and scroll-linked scenes. CX Codex +1 997installs canvas-generative A skill for algorithmic graphics on Canvas 2D: particles, flow fields, noise, fractals and L-systems. Useful when you need a browser animation or a generative still. CX Codex +1 995installs framer-motion Guides coding agents on Framer Motion and Motion patterns in React: layout transitions, gestures, and DOM-direct motion values. CX Codex +1 990installs mobile-principles A rule set for mobile interfaces: tap target sizes, no-hover doctrine, thumb zones, safe areas and gestures. Loads when the agent detects a mobile platform. CX Codex +1 974installs compose-multiplatform Provides Compose Multiplatform patterns for Android, iOS, Desktop, and Web. It guides UI architecture and platform interop via expect and actual declarations. CX Codex +1 951installs swiftui-motion Provides SwiftUI animation rules and decision trees for coding agents. Helps configure springs, phase animators, and clean transitions without visual bugs. CX Codex +1 943installs compose-motion A skill for Jetpack Compose animation: picks the right API for the job and hands over ready spring specs. Built for Android and Compose Multiplatform. CX Codex +1 943installs

Project sponsors

Companies that keep this analytics open
Gold sponsor

Impressions, clicks and code copies — open statistics. We keep the numbers public for the same reason NeBlask itself is public. Become a sponsor