Files
amethyst/.claude/skills/desktop-expert/SKILL.md
T
Claude 09825950c7 docs: one UI for Android and Desktop, and the measured Wave 4 plan
Android now ships on laptops, so the whole UI (screens and the navigation
shell) moves to commonsUI, amethyst becomes an Android shim, and a new JVM
desktopApp replaces the current one with the same UI.

- commons/plans/2026-09-27-one-ui-android-desktop.md: the decision, what it
  supersedes in the sweep tracker, the prerequisites (androidx
  navigation-compose publishes only jvmStubs for JVM; the Amethyst.instance
  app root is read by 178 files; Android-only libraries inside screens), and
  Wave 4 measured: Account's move-group is 77 files / ~23.9k lines inside
  model/, with 5 hard-blocked files and 14 exit edges, each with a proposed
  cut; AccountViewModel's Android imports and 29 outside dependencies.
- CLAUDE.md, commons/ARCHITECTURE.md, commonsUI/ARCHITECTURE.md and the
  kotlin-multiplatform, compose-expert and desktop-expert skills no longer say
  screens and navigation stay platform-native.
- The sweep tracker marks its STAY list, Wave 2 part B and the Desktop-phase
  merges as superseded, and re-scopes Wave 4 from decomposition to moving
  Account and AccountViewModel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
2026-09-27 23:00:45 +00:00

767 lines
20 KiB
Markdown

---
name: desktop-expert
description: Compose Multiplatform Desktop patterns for the `desktopApp/` module. Use when working with (1) Desktop-only APIs (Window, WindowState, Tray, MenuBar, Dialog), (2) keyboard shortcuts and menu systems with OS-aware conventions (Cmd vs Ctrl, isMacOS branching), (3) desktop navigation (NavigationRail/sidebar vs Android bottom nav, multi-window), (4) file system integration (file pickers, drag-and-drop, Desktop.getDesktop()), (5) OS-specific behavior on macOS/Windows/Linux, (6) desktop UX principles (keyboard-first, tooltips). Delegates shared composables to compose-expert, build/packaging to gradle-expert, and source-set structure to kotlin-multiplatform.
---
# Desktop Expert
Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles.
## When to Use This Skill
**Auto-invoke when:**
- Working with `desktopApp/` module files
- Using Desktop-only APIs: `Window`, `Tray`, `MenuBar`, `Dialog`
- Implementing keyboard shortcuts, menu systems
- Desktop navigation (NavigationRail, multi-window)
- File system operations (file pickers, drag-drop)
- OS-specific behavior (macOS, Windows, Linux)
- Desktop UX patterns (keyboard-first, tooltips)
**Delegate to:**
- **kotlin-multiplatform**: Shared code questions, `jvmMain` source set structure
- **gradle-expert**: All `build.gradle.kts` issues, dependency conflicts
- **compose-expert**: General Compose patterns, `@Composable` best practices, Material3
## Scope
**In scope:**
- Desktop-only Compose APIs
- Window management, positioning, state
- MenuBar + keyboard shortcuts (OS-specific)
- System Tray integration
- Desktop navigation patterns (NavigationRail)
- File dialogs, Desktop.getDesktop()
- OS conventions (macOS vs Windows vs Linux)
- Desktop UX principles
**Out of scope:**
- Build configuration → **gradle-expert**
- Shared composables → **compose-expert**
- KMP structure → **kotlin-multiplatform**
---
## 1. Desktop Entry Point
### application {} DSL
Desktop apps start with the `application {}` block:
```kotlin
// desktopApp/src/jvmMain/kotlin/Main.kt
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst"
) {
MenuBar { /* ... */ }
App()
}
}
```
**Key points:**
- `application {}` is the root composable (JVM-only)
- `Window()` creates the main window
- `rememberWindowState()` manages size/position
- `onCloseRequest` handles window close
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — grep for `fun main()`, `application {`, the top-level `Window`, and `MenuBar {` (the file is large and line numbers drift; navigate by symbol).
---
## 2. Window Management
### WindowState
```kotlin
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
Window(
state = windowState,
title = "My App",
resizable = true,
onCloseRequest = ::exitApplication
) {
// Content
}
```
### Multiple Windows
```kotlin
fun main() = application {
var showSettings by remember { mutableStateOf(false) }
Window(onCloseRequest = ::exitApplication, title = "Main") {
Button(onClick = { showSettings = true }) {
Text("Open Settings")
}
}
if (showSettings) {
Window(
onCloseRequest = { showSettings = false },
title = "Settings"
) {
// Settings UI
}
}
}
```
**Pattern:** Use state to control window visibility conditionally.
---
## 3. MenuBar System
### Basic MenuBar
```kotlin
Window(onCloseRequest = ::exitApplication, title = "App") {
MenuBar {
Menu("File") {
Item("New Note", onClick = { /* ... */ })
Separator()
Item("Quit", onClick = ::exitApplication)
}
Menu("Edit") {
Item("Copy", onClick = { /* ... */ })
Item("Paste", onClick = { /* ... */ })
}
}
App()
}
```
### Keyboard Shortcuts (OS-Aware)
**Current state:** `Main.kt` already branches on `isMacOS` (declared at L120) for every menu shortcut — `if (isMacOS) { KeyShortcut(..., meta = true) } else { KeyShortcut(..., ctrl = true) }` (see L239, L249, L286, L313, L325, L335, L347, L358, L374, L384, L400, L416, L449). When adding a new shortcut, follow the same branching pattern rather than hardcoding `ctrl = true`.
**OS-specific shortcuts:**
```kotlin
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut
// Detect OS
val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
MenuBar {
Menu("File") {
Item(
"New Note",
shortcut = if (isMacOS) {
KeyShortcut(Key.N, meta = true) // Cmd+N on macOS
} else {
KeyShortcut(Key.N, ctrl = true) // Ctrl+N on Win/Linux
},
onClick = { /* ... */ }
)
Item(
"Settings",
shortcut = if (isMacOS) {
KeyShortcut(Key.Comma, meta = true) // Cmd+, on macOS
} else {
KeyShortcut(Key.Comma, ctrl = true) // Ctrl+, on Win/Linux
},
onClick = { /* ... */ }
)
Separator()
Item(
"Quit",
shortcut = if (isMacOS) {
KeyShortcut(Key.Q, meta = true) // Cmd+Q on macOS
} else {
KeyShortcut(Key.Q, ctrl = true) // Ctrl+Q on Win/Linux
},
onClick = ::exitApplication
)
}
}
```
**Standard shortcuts:**
| Action | macOS | Windows/Linux |
|--------|-------|---------------|
| New | Cmd+N | Ctrl+N |
| Open | Cmd+O | Ctrl+O |
| Save | Cmd+S | Ctrl+S |
| Quit | Cmd+Q | Ctrl+Q (Alt+F4) |
| Settings | Cmd+, | Ctrl+, |
| Copy | Cmd+C | Ctrl+C |
| Paste | Cmd+V | Ctrl+V |
| Undo | Cmd+Z | Ctrl+Z |
**See:** `references/keyboard-shortcuts.md` for full list.
---
## 4. System Tray
### Basic Tray
```kotlin
application {
var isVisible by remember { mutableStateOf(true) }
Tray(
icon = painterResource("icon.png"),
onAction = { isVisible = true },
menu = {
Item("Show", onClick = { isVisible = true })
Separator()
Item("Quit", onClick = ::exitApplication)
}
)
if (isVisible) {
Window(
onCloseRequest = { isVisible = false }, // Minimize to tray
title = "App"
) {
// Content
}
}
}
```
**Pattern:** Hide window to tray instead of closing.
**Current status:** Not implemented in Main.kt. Planned feature.
---
## 5. Desktop Navigation Patterns
### NavigationRail (Current Pattern — legacy)
> **Being replaced.** Android now also ships on laptops, and the plan is one UI: the
> shared navigation shell (bottom bar / rail / permanent drawer picked by window size
> via `ScreenLayoutSpec`) moves to `commonsUI`, and a new `desktopApp` becomes a JVM
> shim that renders it. Don't grow this sidebar shell or add Desktop-only screens; see
> `commons/plans/2026-09-27-one-ui-android-desktop.md`. What stays Desktop-specific is
> the window, tray, menu bar, keyboard shortcuts and file system integration.
Today's desktop app uses **NavigationRail** (vertical sidebar) instead of Android's bottom navigation.
```kotlin
Row(Modifier.fillMaxSize()) {
// Sidebar
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { currentScreen = AppScreen.Feed }
)
// More items...
}
VerticalDivider()
// Main content area
Box(Modifier.weight(1f).fillMaxHeight()) {
when (currentScreen) {
AppScreen.Feed -> FeedScreen()
// Other screens...
}
}
}
```
**In Amethyst Desktop:** the sidebar is the custom `MainSidebar` composable in `desktopApp/.../ui/deck/DeckSidebar.kt`, instantiated from `Main.kt` and shared by both layout modes (`SinglePaneLayout` and the multi-pane `DeckLayout` alongside it). It is hand-rolled, not Material's `NavigationRail` — use `NavigationRail` only for new, simpler cases.
**Why a left sidebar?**
- Desktop has horizontal space (1200+ dp width)
- Vertical sidebar is standard desktop pattern
- Always visible (no tabs hidden)
- Icon + label both visible
**Android comparison:**
- Android: `BottomNavigationBar` (horizontal, bottom)
- Desktop: left vertical sidebar (`MainSidebar`)
### Multi-Pane Layouts
Desktop can leverage wide screens:
```kotlin
Row {
// Left: Navigation
NavigationRail { /* ... */ }
// Center: Main content
Box(Modifier.weight(0.6f)) {
FeedScreen()
}
// Right: Details pane (desktop only)
if (selectedNote != null) {
VerticalDivider()
Box(Modifier.weight(0.4f)) {
NoteDetailPane(selectedNote)
}
}
}
```
**See:** `references/desktop-navigation.md`
---
## 6. File System Integration
### File Dialogs
```kotlin
// File picker (load)
val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD)
fileDialog.isVisible = true
val filePath = fileDialog.file?.let { "${fileDialog.directory}$it" }
// File picker (save)
val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE)
saveDialog.isVisible = true
val savePath = saveDialog.file?.let { "${saveDialog.directory}$it" }
```
**Note:** Compose Desktop doesn't have native file picker composable yet. Use AWT `FileDialog`.
### Open External URLs
```kotlin
// jvmMain actual implementation
actual fun openExternalUrl(url: String) {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().browse(URI(url))
}
}
```
**Pattern:** Define `expect` in `commonMain`, implement `actual` in `jvmMain`.
### Drag & Drop (Future)
```kotlin
// Compose Desktop drag-drop (experimental)
Box(
modifier = Modifier
.onExternalDrag(
onDragStart = { /* ... */ },
onDrag = { /* ... */ },
onDragExit = { /* ... */ },
onDrop = { state ->
val dragData = state.dragData
// Handle dropped files
}
)
) {
Text("Drop files here")
}
```
---
## 7. OS-Specific Conventions
### Platform Detection
```kotlin
val osName = System.getProperty("os.name").lowercase()
val isMacOS = osName.contains("mac")
val isWindows = osName.contains("win")
val isLinux = osName.contains("nux") || osName.contains("nix")
```
### Menu Bar Placement
| OS | Behavior |
|----|----------|
| **macOS** | System-wide menu bar at top of screen |
| **Windows** | In-window menu bar |
| **Linux** | Varies by desktop environment |
Compose Desktop `MenuBar` adapts automatically.
### Keyboard Modifier Keys
| Modifier | macOS | Windows/Linux |
|----------|-------|---------------|
| Primary | `meta = true` (Cmd) | `ctrl = true` |
| Secondary | `ctrl = true` | `alt = true` |
| Shift | `shift = true` | `shift = true` |
**Best practice:** Detect OS and use appropriate modifier.
### System Tray Behavior
| OS | Tray Location |
|----|---------------|
| **macOS** | Top-right menu bar |
| **Windows** | Bottom-right taskbar |
| **Linux** | Top panel (varies) |
---
## 8. Desktop UX Principles
### Keyboard-First Design
**Every action should have:**
1. Mouse/touch interaction
2. Keyboard shortcut (if frequent)
3. Tooltip showing shortcut
```kotlin
IconButton(
onClick = { /* refresh */ },
modifier = Modifier.tooltipArea(
tooltip = {
Text("Refresh (${if (isMacOS) "Cmd" else "Ctrl"}+R)")
}
)
) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
### Tooltip Best Practices
- Show keyboard shortcut in tooltip
- Use native modifier name (Cmd vs Ctrl)
- Brief description + shortcut
### Context Menus
Right-click should show context menu:
```kotlin
// Future: Compose Desktop context menu API
Box(
modifier = Modifier.contextMenuArea(
items = {
listOf(
ContextMenuItem("Copy") { /* ... */ },
ContextMenuItem("Paste") { /* ... */ }
)
}
)
) {
// Content
}
```
**Current:** Use popup or custom implementation.
### Window State Persistence
Save/restore window size/position:
```kotlin
// Save on close
windowState.size // DpSize
windowState.position // WindowPosition
// Restore on launch
val savedWidth = preferences.getInt("window.width", 1200)
val savedHeight = preferences.getInt("window.height", 800)
val windowState = rememberWindowState(
width = savedWidth.dp,
height = savedHeight.dp
)
```
---
## 9. Desktop Module Structure
```
desktopApp/
├── build.gradle.kts # Desktop-only build config
└── src/
└── jvmMain/
├── kotlin/
│ └── com/vitorpamplona/amethyst/desktop/
│ ├── Main.kt # Entry point, Window, MenuBar
│ ├── network/
│ │ ├── DesktopHttpClient.kt
│ │ └── DesktopRelayConnectionManager.kt
│ └── ui/
│ ├── FeedScreen.kt # Desktop screen layouts
│ └── LoginScreen.kt
└── resources/
├── icon.icns # macOS icon
├── icon.ico # Windows icon
└── icon.png # Linux icon
```
**Key files:**
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — `fun main()` L172, `application {` L186, `Window` L229, `MenuBar` L234 (OS-aware shortcuts begin at L239)
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` — NavigationRail at L97
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/` — DeckLayout, WorkspaceManager, DeckState (multi-pane)
- `desktopApp/build.gradle.kts` — desktop packaging config (DMG/MSI/DEB)
---
## 10. Packaging & Distribution
### Build Configuration
```kotlin
// desktopApp/build.gradle.kts
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "Amethyst"
packageVersion = "1.0.0"
macOS {
bundleID = "com.vitorpamplona.amethyst.desktop"
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
}
windows {
iconFile.set(project.file("src/jvmMain/resources/icon.ico"))
menuGroup = "Amethyst"
}
linux {
iconFile.set(project.file("src/jvmMain/resources/icon.png"))
}
}
}
}
```
**See:** desktopApp/build.gradle.kts:45-73
### Gradle Tasks
```bash
# Run desktop app
./gradlew :desktopApp:run
# Package for distribution
./gradlew :desktopApp:packageDmg # macOS
./gradlew :desktopApp:packageMsi # Windows
./gradlew :desktopApp:packageDeb # Linux
```
**Delegate packaging issues to gradle-expert.**
---
## Common Patterns
### Pattern: OS-Aware Shortcuts Helper
```kotlin
// commons/src/jvmMain/kotlin/shortcuts/ShortcutUtils.kt
object DesktopShortcuts {
private val isMacOS = System.getProperty("os.name")
.lowercase().contains("mac")
fun primary(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true)
} else {
KeyShortcut(key, ctrl = true)
}
fun primaryShift(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, shift = true)
} else {
KeyShortcut(key, ctrl = true, shift = true)
}
val modifierName = if (isMacOS) "Cmd" else "Ctrl"
}
// Usage in MenuBar
Item(
"New Note",
shortcut = DesktopShortcuts.primary(Key.N),
onClick = { /* ... */ }
)
```
### Pattern: Shared Composables, Platform Layouts
```kotlin
// commons/commonMain - Shared NoteCard
@Composable
fun NoteCard(note: NoteDisplayData) {
// Business logic, UI component (shared)
}
// desktopApp/jvmMain - Desktop layout
@Composable
fun FeedScreen() {
Column {
FeedHeader(/* ... */) // Shared from commons
LazyColumn {
items(notes) { note ->
NoteCard(note) // Shared composable
}
}
}
}
// amethyst/androidMain - Android layout
@Composable
fun FeedScreen() {
Scaffold(
bottomBar = { BottomNavigationBar() } // Android-specific
) {
LazyColumn {
items(notes) { note ->
NoteCard(note) // Same shared composable
}
}
}
}
```
**Philosophy:** Share UI components (cards, buttons), keep navigation/layout platform-specific.
---
## Resources
### Official Documentation
- [Desktop-only API | Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html)
- [Top-level windows management](https://kotlinlang.org/docs/multiplatform/compose-desktop-top-level-windows-management.html)
- [Tray/MenuBar Tutorial](https://github.com/JetBrains/compose-multiplatform/blob/master/tutorials/Tray_Notifications_MenuBar_new/README.md)
### Bundled References
- `references/desktop-compose-apis.md` - Complete Desktop API catalog
- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns
- `references/keyboard-shortcuts.md` - Standard shortcuts by OS
- `references/os-detection.md` - Platform detection patterns
### Codebase Examples
- `Main.kt` — Window + MenuBar entry point (`application` L186, `Window` L229, `MenuBar` L234)
- `ui/deck/SinglePaneLayout.kt` — NavigationRail at L97
- `ui/deck/DeckLayout.kt` / `WorkspaceManager.kt` — multi-pane workspace
- `ui/feed/` — Desktop feed screens
- `ui/login/` — Centered desktop login
---
## Questions to Ask
When working on desktop features:
1. **Should this be shared or desktop-only?**
- Business logic → Share in `commonMain`
- Navigation/layout → Keep in `desktopApp/jvmMain`
2. **Does this need OS-specific behavior?**
- Keyboard shortcuts → Yes (Cmd vs Ctrl)
- File paths → Yes (separators)
- Icons → Yes (per-OS formats)
3. **Is there a desktop UX convention?**
- Check MenuBar standards
- Consider keyboard-first design
- Tooltips for all actions
4. **Does this need gradle-expert?**
- Any `build.gradle.kts` changes → Delegate
- Packaging/distribution issues → Delegate
---
## Anti-Patterns
❌ **Hardcoding Ctrl everywhere**
```kotlin
// Do NOT do this in a new shortcut:
shortcut = KeyShortcut(Key.N, ctrl = true) // Wrong on macOS
```
✅ **OS-aware shortcuts (the pattern `Main.kt` already uses)**
```kotlin
shortcut = if (isMacOS) {
KeyShortcut(Key.N, meta = true) // Cmd+N on macOS
} else {
KeyShortcut(Key.N, ctrl = true) // Ctrl+N on Win/Linux
}
```
---
❌ **Using Android navigation on Desktop**
```kotlin
Scaffold(bottomBar = { BottomNavigationBar() }) // Wrong for desktop
```
✅ **NavigationRail for desktop**
```kotlin
Row {
NavigationRail { /* ... */ }
MainContent()
}
```
---
❌ **No keyboard shortcuts**
```kotlin
IconButton(onClick = { refresh() }) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
✅ **Shortcuts + tooltips**
```kotlin
IconButton(
onClick = { refresh() },
modifier = Modifier.tooltipArea("Refresh (Cmd+R)")
) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
---
## Next Steps
When implementing desktop features:
1. **Read** `references/desktop-compose-apis.md` for API catalog
2. **Check** `references/keyboard-shortcuts.md` for standard shortcuts
3. **Reference** `Main.kt` (entry point L172-L450+) and `ui/deck/SinglePaneLayout.kt` (NavigationRail) for current patterns
4. **Test** on all 3 platforms (macOS, Windows, Linux) if possible
5. **Delegate** build issues to gradle-expert
6. **Share** UI components via compose-expert, not desktop-expert
---
**Version:** 1.0.0
**Last Updated:** 2025-12-30
**Codebase Reference:** AmethystMultiplatform commit 258c4e011