PluginBench
Skill
Review
Audit score 70

compose-expert

vitorpamplona/amethyst

Advanced Compose Multiplatform UI patterns for shared composables across Android and Desktop.

What is compose-expert?

Expert guidance for building and optimizing shared UI components in Compose Multiplatform projects. Use when creating visual UI components, managing state patterns (remember, derivedStateOf, produceState), optimizing recomposition, applying Material3 theming, or deciding whether to share composables in commonMain versus keeping them platform-specific.

  • Design shared composables using Material3 primitives and state-down/events-up patterns
  • Implement state management with remember, derivedStateOf, and produceState for performance
  • Optimize recomposition using @Immutable and @Stable annotations on data classes
  • Build custom ImageVector icons and theme utilities for cross-platform consistency
  • Determine when to share UI in commonMain versus delegate to platform-specific implementations
  • Structure screens and navigation as shared UI that adapts via ScreenLayoutSpec and LocalScreenLayout

How to install compose-expert

npx skills add https://github.com/vitorpamplona/amethyst --skill compose-expert
Prerequisites
  • Compose Multiplatform project structure with commonMain and platform-specific modules
  • Basic understanding of Compose composables and recomposition
  • Familiarity with Material3 design system
Claude Code
Cursor
Windsurf
Cline

How to use compose-expert

  1. 1.Identify the UI component or screen you need to build or refactor
  2. 2.Check the decision framework: does it use only Material3 primitives and avoid platform APIs?
  3. 3.If yes, implement in commonMain following the shared composable anatomy (state parameters, events, modifier, optional customization)
  4. 4.Apply state management: use remember for simple UI state, derivedStateOf for derived values, produceState for async operations
  5. 5.Mark data classes passed to composables with @Immutable to optimize recomposition
  6. 6.For platform entry points (Activity, Window, tray) or system integrations, delegate to android-expert or desktop-expert

Use cases

Good for
  • Creating reusable button, card, list, and dialog components shared across Android and Desktop
  • Converting async operations (Flow, LiveData, callbacks) into Compose state with produceState
  • Optimizing scroll-to-top buttons and other derived state to prevent unnecessary recompositions
  • Building Material3-themed custom icons and color utilities for visual consistency
  • Refactoring platform-specific UI into shared composables by removing system API dependencies
Who it's for
  • Compose Multiplatform developers building shared UI layers
  • Android developers extending apps to Desktop with shared composables
  • UI engineers optimizing recomposition performance in large Compose projects
  • Teams standardizing Material3 theming and component patterns across platforms

compose-expert FAQ

When should I share UI in commonMain versus keeping it platform-specific?

Default to sharing in commonMain unless the component requires platform system APIs (file pickers, notifications, camera). Pure visual components using Material3 primitives should always be shared. If uncertain, share first—it's easier to split later than merge.

What's the difference between remember, derivedStateOf, and produceState?

Use remember for simple UI state (toggles, counters). Use derivedStateOf when input changes frequently but derived result changes rarely (e.g., scroll position → show button). Use produceState to convert async operations (Flow, callbacks) into Compose state.

How do I prevent unnecessary recompositions?

Mark data classes passed to composables with @Immutable so Compose skips recomposition when the same instance is passed. Use derivedStateOf to filter state changes that don't affect the UI. Keep state hoisted and pass only what each composable needs.

Should screens and navigation be shared or platform-specific?

Screens and navigation are shared in commonMain. They adapt to different window sizes and platforms using ScreenLayoutSpec and LocalScreenLayout, not per-platform implementations. Only entry points (Activity, Window, tray) are platform-specific.

How do I structure a shared composable?

Follow: required state parameters, required event callbacks, modifier parameter, optional customization parameters. State flows down, events flow up. Use Material3 primitives and shared constants for styling consistency.

Full instructions (SKILL.md)

Source of truth, from vitorpamplona/amethyst.


name: compose-expert description: Advanced Compose Multiplatform UI patterns for shared composables. Use when working with visual UI components, state management patterns (remember, derivedStateOf, produceState), recomposition optimization (@Stable/@Immutable visual usage), Material3 theming, custom ImageVector icons, or determining whether to share UI in commonMain vs keep platform-specific. Delegates platform entry points (Activity, Window, tray) to android-expert/desktop-expert; the navigation shell itself is shared UI. Complements kotlin-expert (handles Kotlin language aspects of state/annotations).

Compose Multiplatform Expert

Visual UI patterns for sharing composables across Android and Desktop.

When to Use This Skill

  • Creating or refactoring shared UI components
  • Deciding whether to share UI in commonMain or keep platform-specific
  • Building custom ImageVector icons (robohash pattern)
  • State management: remember, derivedStateOf, produceState
  • Recomposition optimization: visual usage of @Stable/@Immutable
  • Material3 theming and styling
  • Performance: lazy lists, image loading

Delegate to other skills:

  • Navigation structure → android-expert, desktop-expert
  • Kotlin state patterns (StateFlow, sealed classes) → kotlin-expert
  • Build configuration → gradle-expert

Philosophy: Share by Default

Default to commonsUI/commonMain (shared composables live in :commonsUI, the Compose half of the shared layer; headless state/ViewModels stay in :commons) unless platform experts indicate otherwise.

Always Share

  • UI components: Buttons, cards, lists, dialogs, inputs
  • State visualization: Loading, empty, error states
  • Custom icons: ImageVector assets (robohash, custom paths)
  • Theme utilities: Color calculations, style helpers
  • Material3 components: Any UI using Material primitives

Screens and navigation are shared too. Android also ships on laptops and the new Desktop app renders the same UI, so screens, the nav host and the bottom bar / rail / permanent drawer all belong in commonsUI. They adapt to the window with ScreenLayoutSpec / LocalScreenLayout, not per platform. See commons/plans/2026-09-27-one-ui-android-desktop.md.

Keep Platform-Specific

  • Entry points: Android Activity/Service, Desktop Window, tray, menu bar
  • System integrations: File pickers, notifications, share sheets, camera, media3, WebView
  • Platform leaves inside shared UI: behind expect/actual or a slot the shim fills

Decision Framework

  1. Uses only Material3 primitives? → Share in commonMain
  2. Requires platform system APIs? → Platform-specific
  3. Pure visual component without navigation? → Share in commonMain
  4. Needs platform UX patterns? → Ask android-expert or desktop-expert

If uncertain, default to sharing - easier to split later than merge.

Shared Composable Anatomy

Structure

@Composable
fun SharedComponent(
    // State parameters (read-only)
    data: DataClass,
    isLoading: Boolean,
    // Event parameters (write-only)
    onAction: () -> Unit,
    // Visual parameters
    modifier: Modifier = Modifier,
    // Optional customization
    colors: ComponentColors = ComponentDefaults.colors()
) {
    // Implementation
}

Pattern: State down, events up

  • Parameters above modifier = required state/events
  • modifier parameter = layout control
  • Parameters below modifier = optional customization

Example: AddButton

@Composable
fun AddButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    text: String = "Add",
    enabled: Boolean = true
) {
    OutlinedButton(
        modifier = modifier,
        enabled = enabled,
        onClick = onClick,
        shape = ActionButtonShape,
        contentPadding = ActionButtonPadding
    ) {
        Text(text = text, textAlign = TextAlign.Center)
    }
}

// Shared constants for consistency
val ActionButtonShape = RoundedCornerShape(20.dp)
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)

Why this works on all platforms:

  • Material3 primitives (OutlinedButton, Text)
  • No platform APIs
  • Configurable through parameters
  • Consistent styling via shared constants

State Management Patterns

remember - Cache Across Recompositions

@Composable
fun ExpandableCard() {
    var isExpanded by remember { mutableStateOf(false) }

    Column {
        IconButton(onClick = { isExpanded = !isExpanded }) {
            Icon(
                if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
                contentDescription = if (isExpanded) "Collapse" else "Expand"
            )
        }

        if (isExpanded) {
            Text("Expanded content...")
        }
    }
}

Visual pattern: Toggle button → state changes → UI expands/collapses Use for: Simple UI state (toggles, counters, text input)

derivedStateOf - Optimize Frequent Changes

@Composable
fun ScrollToTopButton(listState: LazyListState) {
    // Only recomposes when showButton changes, not every scroll pixel
    val showButton by remember {
        derivedStateOf {
            listState.firstVisibleItemIndex > 0
        }
    }

    if (showButton) {
        FloatingActionButton(onClick = { /* scroll to top */ }) {
            Icon(Icons.Default.ArrowUpward, null)
        }
    }
}

Visual pattern: Scroll position (0, 1, 2...) → boolean (show/hide) → Button visibility Use for: Input changes frequently, derived result changes rarely Performance: Prevents recomposition on every scroll event

produceState - Async to Compose State

@Composable
fun LoadUserProfile(userId: String): State<User?> {
    return produceState<User?>(initialValue = null, userId) {
        value = repository.fetchUser(userId)
    }
}

@Composable
fun ProfileScreen(userId: String) {
    val user by LoadUserProfile(userId)

    when (user) {
        null -> LoadingState("Loading profile...")
        else -> ProfileCard(user!!)
    }
}

Visual pattern: Async operation → state updates → UI reflects changes Use for: Convert Flow, LiveData, callbacks into Compose state Lifecycle: Coroutine cancelled when composable leaves composition

For Kotlin-specific state patterns (StateFlow, sealed classes), see kotlin-expert.

State Hoisting

Move state up to make composables reusable:

// ❌ Stateful - hard to test, can't control externally
@Composable
fun BadSearchBar() {
    var query by remember { mutableStateOf("") }
    TextField(value = query, onValueChange = { query = it })
}

// ✅ Stateless - reusable, testable
@Composable
fun GoodSearchBar(
    query: String,
    onQueryChange: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    TextField(
        value = query,
        onValueChange = onQueryChange,
        modifier = modifier
    )
}

@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }

    Column {
        GoodSearchBar(query = query, onQueryChange = { query = it })
        SearchResults(query = query)
    }
}

Principle: State up, events down

  • State: query: String (read-only parameter)
  • Events: onQueryChange: (String) -> Unit (callback parameter)

Recomposition Optimization

Visual Usage of @Immutable

Use @Immutable on data classes passed to composables:

@Immutable
data class UserProfile(val name: String, val avatar: String)

@Composable
fun ProfileCard(profile: UserProfile) {
    // Only recomposes when profile instance changes
    Row {
        RobohashImage(robot = profile.avatar)
        Text(profile.name, style = MaterialTheme.typography.titleMedium)
    }
}

Visual effect: Prevents recomposition when parent recomposes with same data Pattern: Mark parameter data classes as @Immutable Note: For Kotlin language details on @Immutable, see kotlin-expert

Stable Parameters

// ✅ Stable - won't trigger recomposition unless colors instance changes
@Composable
fun ThemedCard(
    content: String,
    colors: CardColors = CardDefaults.colors(),
    modifier: Modifier = Modifier
) {
    Card(colors = colors, modifier = modifier) {
        Text(content)
    }
}

For @Stable annotation details, see kotlin-expert.

Material3 Theming

All shared composables use Material3 for consistency:

@Composable
fun ThemedComponent() {
    val bg = MaterialTheme.colorScheme.background
    val fg = MaterialTheme.colorScheme.onBackground
    val primary = MaterialTheme.colorScheme.primary

    Column(
        modifier = Modifier.background(bg)
    ) {
        Text(
            "Title",
            style = MaterialTheme.typography.headlineMedium,
            color = fg
        )
        Button(
            onClick = { /* ... */ },
            colors = ButtonDefaults.buttonColors(containerColor = primary)
        ) {
            Text("Action")
        }
    }
}

Principles:

  • Colors: MaterialTheme.colorScheme.*
  • Typography: MaterialTheme.typography.*
  • Shapes: MaterialTheme.shapes.*

Theme Detection

@Composable
private fun isLightTheme(): Boolean {
    val background = MaterialTheme.colorScheme.background
    return (background.red + background.green + background.blue) / 3 > 0.5f
}

@Composable
fun ThemedIcon() {
    val isDark = !isLightTheme()
    val tint = if (isDark) Color.White else Color.Black
    Icon(Icons.Default.Face, null, tint = tint)
}

Custom Icons: ImageVector Pattern

Amethyst uses ImageVector for multiplatform icons.

roboBuilder DSL

fun roboBuilder(block: Builder.() -> Unit): ImageVector {
    return ImageVector.Builder(
        name = "Robohash",
        defaultWidth = 300.dp,
        defaultHeight = 300.dp,
        viewportWidth = 300f,
        viewportHeight = 300f
    ).apply(block).build()
}

Building Icons

fun customIcon(fgColor: SolidColor, builder: Builder) {
    builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
    builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
    builder.addPath(pathData3, fill = Black, fillAlpha = 0.2f)
}

private val pathData1 = PathData {
    moveTo(144.5f, 87.5f)
    reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
    lineToRelative(16.0f, 16.0f)
    close()
}

@Composable
fun CustomIcon() {
    Image(
        painter = rememberVectorPainter(
            roboBuilder {
                customIcon(SolidColor(Color.Blue), this)
            }
        ),
        contentDescription = "Custom icon"
    )
}

Why ImageVector?

  • Pure Kotlin, no XML
  • Works on Android, Desktop, iOS
  • GPU-accelerated
  • Type-safe

Caching Pattern

object CustomIcons {
    private val cache = mutableMapOf<String, ImageVector>()

    fun get(key: String): ImageVector {
        return cache.getOrPut(key) {
            buildIcon(key)
        }
    }
}

@Composable
fun CachedIcon(key: String) {
    Image(imageVector = CustomIcons.get(key), contentDescription = null)
}

For detailed icon patterns, see references/icon-assets.md.

Common Visual Patterns

State Visualization

@Composable
fun DataScreen(uiState: UiState) {
    when (uiState) {
        is UiState.Loading -> LoadingState("Loading...")
        is UiState.Empty -> EmptyState(
            title = "No data",
            onRefresh = { /* refresh */ }
        )
        is UiState.Error -> ErrorState(
            message = uiState.message,
            onRetry = { /* retry */ }
        )
        is UiState.Success -> ContentList(uiState.items)
    }
}

Components (all in commonsUI/commonMain):

  • LoadingState - Progress indicator + message
  • EmptyState - Empty message + optional refresh button
  • ErrorState - Error message + optional retry button

Relay Status (Amethyst Pattern)

@Composable
fun RelayStatusIndicator(connectedCount: Int) {
    val statusColor = when {
        connectedCount == 0 -> RelayStatusColors.Disconnected
        connectedCount < 3 -> RelayStatusColors.Connecting
        else -> RelayStatusColors.Connected
    }

    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
        Icon(
            imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
            tint = statusColor,
            modifier = Modifier.size(16.dp)
        )
        Text(
            "$connectedCount relay${if (connectedCount != 1) "s" else ""}",
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
    }
}

Visual mapping:

  • 0 relays → Red + X icon
  • 1-2 relays → Yellow + Check icon
  • 3+ relays → Green + Check icon

Placeholder Pattern

@Composable
fun PlaceholderScreen(
    title: String,
    description: String,
    modifier: Modifier = Modifier
) {
    Column(modifier = modifier) {
        Text(title, style = MaterialTheme.typography.headlineMedium)
        Spacer(Modifier.height(16.dp))
        Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant)
    }
}

// Specific implementations
@Composable
fun SearchPlaceholder() = PlaceholderScreen(
    title = "Search",
    description = "Search for users, notes, and hashtags."
)

Pattern: Generic composable + specific wrappers with preset text

Performance

Avoid Unnecessary Recomposition

// ❌ Bad - recomposes on every scroll
@Composable
fun BadButton(scrollState: ScrollState) {
    if (scrollState.value > 100) {
        Button(onClick = {}) { Text("Top") }
    }
}

// ✅ Good - only recomposes when visibility changes
@Composable
fun GoodButton(scrollState: ScrollState) {
    val show by remember { derivedStateOf { scrollState.value > 100 } }
    if (show) {
        Button(onClick = {}) { Text("Top") }
    }
}

Lazy Lists

@Composable
fun FeedList(items: List<Item>) {
    LazyColumn {
        items(items, key = { it.id }) { item ->
            FeedItem(item)
        }
    }
}

Key principle: Use key parameter for stable item identity

Bundled Resources

  • references/shared-composables-catalog.md - Complete catalog of shared UI components
  • references/state-patterns.md - State management patterns with visual examples
  • references/icon-assets.md - Custom ImageVector icon patterns
  • references/rich-text-parsing.md - RichTextParser, UrlParser, GalleryParser, Patterns, MediaContentModels; NIP-92 imeta enrichment
  • scripts/find-composables.sh - Find all @Composable functions in codebase

Quick Reference

TaskPatternLocation
Reusable UIState hoistingcommonsUI/commonMain
Simple stateremember { mutableStateOf() }Composable scope
Derived statederivedStateOf { }remember block
Async → stateproduceState { }Composable function
Custom iconsroboBuilder + PathDatacommonsUI/icons
Loading/ErrorLoadingState, ErrorStatecommonsUI/ui/components
Theme colorsMaterialTheme.colorSchemeAny @Composable
NavigationDelegate to platform expertamethyst/, desktopApp/

Common Workflows

Creating a Shared Component

  1. Start in commonsUI/src/commonMain/kotlin/.../ui/components/
  2. Use Material3 primitives only
  3. Hoist state (parameters for data, callbacks for events)
  4. Add modifier parameter
  5. Use MaterialTheme for colors/typography
  6. Test on both Android and Desktop

Converting Existing Component

  1. Read current implementation in amethyst/ or desktopApp/
  2. Identify pure visual logic (no platform APIs)
  3. Create in commonsUI/commonMain with hoisted state
  4. Replace platform implementations with shared component
  5. Keep platform-specific wrappers if needed

Custom Icon

  1. Export SVG from design tool
  2. Convert to PathData using Android Studio
  3. Create icon function with roboBuilder
  4. Add caching if generated dynamically
  5. Wrap in @Composable for easy use

Navigation

The navigation shell is shared (ScreenLayoutSpec picks bottom bar, rail or permanent drawer by window size) and is moving to commonsUI with AppNavigation. Delegate only the platform leaves:

  • Activity / intent plumbing → android-expert
  • Window, tray, menu bar, multi-window → desktop-expert
  • Today's legacy desktopApp sidebar shell → desktop-expert (to be replaced)

Related Skills

  • kotlin-expert - Kotlin language aspects (@Immutable details, StateFlow, sealed classes)
  • android-expert - Android navigation, platform APIs
  • desktop-expert - Desktop navigation, window management, OS specifics
  • kotlin-coroutines - Async patterns, Flow integration