compose-expert

Advanced Compose Multiplatform UI patterns for shared composables. Use when working with visual UI components, state management patterns (remember,…

INSTALLATION
npx skills add https://github.com/vitorpamplona/amethyst --skill compose-expert
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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 commons/commonMain** 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

Keep Platform-Specific

  • Navigation structure: Bottom nav (Android) vs Sidebar (Desktop)
  • Screen layouts: Platform-specific scaffolding
  • System integrations: File pickers, notifications, share sheets
  • Platform UX: Gestures, keyboard shortcuts, window management

Decision Framework

  • Uses only Material3 primitives? → Share in commonMain
  • Requires platform system APIs? → Platform-specific
  • Pure visual component without navigation? → Share in commonMain
  • 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 commons/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

Task

Pattern

Location

Reusable UI

State hoisting

commons/commonMain

Simple state

remember { mutableStateOf() }

Composable scope

Derived state

derivedStateOf { }

remember block

Async → state

produceState { }

Composable function

Custom icons

roboBuilder + PathData

commons/icons

Loading/Error

LoadingState, ErrorState

commons/ui/components

Theme colors

MaterialTheme.colorScheme

Any @Composable

Navigation

Delegate to platform expert

amethyst/, desktopApp/

Common Workflows

Creating a Shared Component

  • Start in commons/src/commonMain/kotlin/.../ui/components/
  • Use Material3 primitives only
  • Hoist state (parameters for data, callbacks for events)
  • Add modifier parameter
  • Use MaterialTheme for colors/typography
  • Test on both Android and Desktop

Converting Existing Component

  • Read current implementation in amethyst/ or desktopApp/
  • Identify pure visual logic (no platform APIs)
  • Create in commons/commonMain with hoisted state
  • Replace platform implementations with shared component
  • Keep platform-specific wrappers if needed

Custom Icon

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

Navigation (Delegate)

For navigation patterns:

  • Android bottom nav → android-expert
  • Desktop sidebar → desktop-expert
  • Multi-window → desktop-expert

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
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card