Loading...
Loading...
Desktop-specific UX principles - hover states, pointer precision, keyboard shortcuts, multi-window, focus management. Covers macOS, Windows, Linux, web desktop.
npx skill4agent add athevon/genjutsu desktop-principlesDesktop UX context. Loaded when desktop is detected (macOS, Windows, Linux desktop, web desktop). Concise rules here. Deep-dive in.references/
:hover.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);
}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
}
}@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") }
}⌘+F| Action | macOS | Windows / Linux |
|---|---|---|
| New | | |
| Close window | | |
| Quit app | | |
| Settings / Preferences | | |
| Find | | |
| Toggle (comment, sidebar...) | | |
| Save | | |
| Command palette | | |
// 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();
}
});Button("New Document", action: newDoc)
.keyboardShortcut("n", modifiers: .command)
Button("Find", action: focusSearch)
.keyboardShortcut("f", modifiers: .command)@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)
}
}@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
}
}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()
}
}
}@ObservablekoinviewModelstruct 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 }
}
}val emailFocus = remember { FocusRequester() }
val passwordFocus = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
TextField(
value = email, onValueChange = { email = it },
modifier = Modifier.focusRequester(emailFocus).focusable(),
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() }.button {
/* Never `outline: none` without an alternative. */
}
.button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}<!-- tabindex="0" puts a non-interactive element in tab order -->
<div role="button" tabindex="0" class="button">Custom button</div>⌘K/* 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); }/* GOOD - 100ms opacity, almost subliminal, never tires */
.card {
opacity: 0.92;
transition: opacity 100ms ease-out;
}
.card:hover { opacity: 1; }<!-- BAD - 1440px viewport, infinite room, but nav is collapsed -->
<header>
<button class="hamburger" aria-label="Menu">☰</button>
</header>
<nav class="drawer hidden">...</nav><!-- 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>// BAD - "New" is buried in a menu, no shortcut, every creation is 3 clicks
<Toolbar>
<Menu>
<MenuItem onClick={newDoc}>New document</MenuItem>
</Menu>
</Toolbar>// 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);/* BAD - keyboard users now have no idea where focus is */
button:focus { outline: none; }/* 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;
}| Need | Load |
|---|---|
| Keyboard patterns deep-dive | |
| Multi-window state | |
| SwiftUI animations | |
| Compose Desktop | |