
Compose Expert
- 1.1k installs
- 1.6k repo stars
- Updated August 4, 2026
- vitorpamplona/amethyst
compose-expert is an agent skill that provides advanced Compose Multiplatform UI patterns for building shared composables that run on both Android and Desktop.
About
compose-expert is a Compose Multiplatform agent skill from the Amethyst project for shared UI across Android and Desktop targets. It guides state patterns with remember, derivedStateOf, and produceState, recomposition tuning with @Stable and @Immutable, Material3 theming, custom ImageVector icons, and whether composables belong in commonMain or platform code. The skill delegates navigation questions to android-expert and desktop-expert while complementing kotlin-expert for language-level state and annotation details. Use it when refactoring or creating shared visual components in a Kotlin Multiplatform app instead of duplicating platform-specific UI implementations.
- Guides decisions on sharing UI in commonMain versus platform-specific code
- Covers state management with remember, derivedStateOf, and produceState
- Optimizes recomposition using @Stable and @Immutable annotations
- Provides Material3 theming, custom ImageVector icons, and lazy list patterns
- Delegates navigation and Kotlin language details to android-expert, desktop-expert, and kotlin-expert
Compose Expert by the numbers
- 1,068 all-time installs (skills.sh)
- +41 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #381 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vitorpamplona/amethyst --skill compose-expertAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.1k |
|---|---|
| repo stars | ★ 1.6k |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | vitorpamplona/amethyst ↗ |
How do you share Compose UI across Android and Desktop?
Get expert guidance on Compose Multiplatform patterns when creating shared UI components that work across Android and Desktop.
Who is it for?
Kotlin Multiplatform developers building shared Compose UI for Android and Desktop who need Material3 and state-management guidance.
Skip if: Teams using Jetpack Compose only on Android or building purely native Swift or web frontends without Compose Multiplatform.
When should I use this skill?
A developer is creating, refactoring, or optimizing shared Compose Multiplatform composables, theming, or state for Android and Desktop.
What you get
Shared composable implementations, commonMain versus platform UI decisions, and optimized recomposition patterns
- shared composable patterns
- commonMain UI structure
- recomposition optimization guidance
Files
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
commonMainor 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
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
modifierparameter = 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 + messageEmptyState- Empty message + optional refresh buttonErrorState- 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
1. Start in commons/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 commons/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 (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
Custom Icon Assets and ImageVector Patterns
Guide to creating and using custom ImageVector icons in Compose Multiplatform.
Why ImageVector?
ImageVector is the native Compose format for vector graphics:
- Pure Kotlin: No XML, no asset files
- Multiplatform: Works on Android, Desktop, iOS without conversion
- Performant: Lightweight, composable, GPU-accelerated
- Type-safe: Compile-time checking, no resource IDs
Amethyst Pattern: Robohash
Amethyst generates deterministic avatars using ImageVector builders.
Architecture
commons/robohash/
├── RobohashAssembler.kt # Main assembly logic
├── CachedRobohash.kt # Caching layer
└── parts/
├── Face0C3po.kt # Face variants (0-9)
├── Eyes2Single.kt # Eye variants (0-9)
├── Mouth3Grid.kt # Mouth variants (0-9)
├── Body2Thinnest.kt # Body variants (0-9)
└── Accessory7Antenna.kt # Accessory variants (0-9)Pattern: 10 variants per feature × 5 features = 100,000+ unique combinations
roboBuilder DSL
Custom ImageVector builder with sensible defaults:
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
return ImageVector.Builder(
name = "Robohash",
defaultWidth = 300.dp,
defaultHeight = 300.dp,
viewportWidth = 300f,
viewportHeight = 300f
).apply(block).build()
}Usage:
@Composable
fun CustomIcon() {
Image(
painter = rememberVectorPainter(
roboBuilder {
// Add paths here
}
),
contentDescription = "Custom icon"
)
}Path Building Pattern
fun face0C3po(fgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
builder.addPath(pathData5, fill = Black, fillAlpha = 0.2f)
builder.addPath(pathData6, stroke = Black, strokeLineWidth = 1.0f)
builder.addPath(pathData7, fill = Black, stroke = Black, fillAlpha = 0.2f, strokeLineWidth = 0.75f)
}
private val pathData1 = PathData {
moveTo(144.5f, 87.5f)
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
curveToRelative(0.0f, 0.0f, 0.0f, 27.0f, 5.0f, 42.0f)
reflectiveCurveToRelative(10.0f, 38.0f, 10.0f, 38.0f)
lineToRelative(16.0f, 16.0f)
// ...
close()
}Key elements:
pathDatavariables for path commandsaddPath()for each layer- Parameterized colors (
fgColor) - Constant colors (
Black) - Alpha for shadows/highlights
PathData DSL
Compose's PathData builder provides SVG-like commands:
| Command | Description | Example |
|---|---|---|
moveTo(x, y) | Move pen without drawing | moveTo(100f, 100f) |
lineTo(x, y) | Draw line to point | lineTo(200f, 150f) |
curveToRelative(...) | Relative cubic Bézier | curveToRelative(10f, 20f, 30f, 40f, 50f, 60f) |
reflectiveCurveToRelative(...) | Smooth curve | reflectiveCurveToRelative(-51f, 3f, -53f, 55f) |
horizontalLineTo(x) | Horizontal line | horizontalLineTo(250f) |
verticalLineTo(y) | Vertical line | verticalLineTo(300f) |
close() | Close path | close() |
Relative vs Absolute:
moveTo/lineTo- Absolute coordinatesmoveToRelative/lineToRelative- Relative to current position
Creating Custom Icons
Method 1: From SVG (Recommended)
1. Export SVG from design tool (Figma, Illustrator) 2. Convert to ImageVector using Android Studio's Vector Asset tool 3. Extract path data and adapt to roboBuilder pattern
// SVG path: M 10 10 L 20 20 ...
// Becomes:
private val myIconPath = PathData {
moveTo(10f, 10f)
lineTo(20f, 20f)
// ...
}Method 2: Programmatic
Build paths programmatically for simple shapes:
fun simpleIcon(): ImageVector = roboBuilder {
addPath(
pathData = PathData {
moveTo(50f, 50f)
lineTo(150f, 50f)
lineTo(150f, 150f)
lineTo(50f, 150f)
close()
},
fill = SolidColor(Color.Blue),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f
)
}Method 3: Material Icons Extensions
Extend Material Icons when you need platform-consistent icons:
// For standard icons, use Material Icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
Icon(Icons.Default.Check, contentDescription = "Success")
Icon(Icons.Default.Close, contentDescription = "Error")CachedRobohash Pattern
Performance optimization for generated icons:
object CachedRobohash {
private val cache = mutableMapOf<Pair<String, Boolean>, ImageVector>()
fun get(seed: String, isLight: Boolean): ImageVector {
return cache.getOrPut(seed to isLight) {
RobohashAssembler.assemble(seed, isLight)
}
}
}Pattern:
- Key:
(seed, theme)pair - Value: Assembled ImageVector
- Lifecycle: Application lifetime (never cleared)
Usage:
@Composable
fun RobohashImage(robot: String) {
Image(
imageVector = CachedRobohash.get(robot, isLightTheme()),
contentDescription = "Avatar for $robot"
)
}Color Management
Dynamic Colors
Pass colors as parameters for theme adaptation:
fun themedIcon(fgColor: SolidColor, bgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = bgColor)
builder.addPath(pathData2, fill = fgColor)
}
@Composable
fun ThemedIcon() {
val fg = MaterialTheme.colorScheme.primary
val bg = MaterialTheme.colorScheme.surface
Image(
painter = rememberVectorPainter(
roboBuilder {
themedIcon(SolidColor(fg), SolidColor(bg), this)
}
),
contentDescription = null
)
}Static Colors
Define constants for colors that don't change:
val Black = SolidColor(Color.Black)
val White = SolidColor(Color.White)
val Transparent = SolidColor(Color.Transparent)Advanced Techniques
Layering
Build complex icons with multiple layers:
fun complexIcon(builder: Builder) {
// Layer 1: Background
builder.addPath(bgPath, fill = SolidColor(Color.White))
// Layer 2: Shadow
builder.addPath(shadowPath, fill = SolidColor(Color.Black), fillAlpha = 0.2f)
// Layer 3: Main shape
builder.addPath(mainPath, fill = SolidColor(Color.Blue))
// Layer 4: Highlight
builder.addPath(highlightPath, fill = SolidColor(Color.White), fillAlpha = 0.3f)
// Layer 5: Stroke
builder.addPath(outlinePath, stroke = SolidColor(Color.Black), strokeLineWidth = 1f)
}Render order: Bottom to top (first addPath = bottom layer)
Alpha for Visual Effects
// Shadow
builder.addPath(shadowPath, fill = Black, fillAlpha = 0.4f)
// Highlight
builder.addPath(highlightPath, fill = White, fillAlpha = 0.2f)
// Glass effect
builder.addPath(glassPath, fill = White, fillAlpha = 0.1f)Stroke Styles
// Outline only
builder.addPath(path, stroke = Black, strokeLineWidth = 1.5f)
// Fill + outline
builder.addPath(path, fill = fgColor, stroke = Black, strokeLineWidth = 1f)
// Dashed (not supported directly, use multiple segments)Composable Icon Pattern
Wrap ImageVector in a Composable for reusability:
@Composable
fun MyCustomIcon(
modifier: Modifier = Modifier,
tint: Color = Color.Unspecified
) {
Image(
painter = rememberVectorPainter(myIconVector()),
contentDescription = "My custom icon",
modifier = modifier,
colorFilter = if (tint != Color.Unspecified) {
ColorFilter.tint(tint)
} else null
)
}Usage:
MyCustomIcon(
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)Best Practices
DO
✅ Cache generated ImageVectors for performance ✅ Use PathData DSL for readability ✅ Parameterize colors for theme support ✅ Use Material Icons for standard icons ✅ Keep viewport size consistent (e.g., 300×300) ✅ Layer paths from back to front ✅ Use alpha for shadows and highlights
DON'T
❌ Generate ImageVectors in @Composable without caching ❌ Hardcode theme-specific colors ❌ Create custom icons for standard Material icons ❌ Use extreme viewport sizes (stay 24-1000dp) ❌ Mix absolute and relative coordinates unnecessarily ❌ Forget to close() paths
Icon Organization
Structure
commons/icons/
├── CustomIcons.kt # Icon collection object
├── icons/
│ ├── Zap.kt # Lightning bolt
│ ├── Relay.kt # Relay indicator
│ └── Bitcoin.kt # Bitcoin symbol
└── builders/
└── IconBuilder.kt # Shared builder utilitiesCollection Object
object CustomIcons {
val Zap: ImageVector by lazy { ZapIcon.create() }
val Relay: ImageVector by lazy { RelayIcon.create() }
val Bitcoin: ImageVector by lazy { BitcoinIcon.create() }
}
// Usage
Icon(CustomIcons.Zap, contentDescription = "Zap")Resources
- Compose ImageVector API
- SVG Path Commands
- Material Icons
- Robohash implementation:
commons/robohash/in AmethystMultiplatform
Rich Text Parsing
Amethyst converts raw event content (plain text with URLs, mentions, hashtags, media links, nostr references, markdown) into structured segments that Compose can render. Everything lives under commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/.
Files
- `RichTextParser.kt` — the main entry point. A
class RichTextParserthat takes a note's content, the URL preview cache, and NIP-92imetatags and returns aRichTextViewState. - `RichTextParserSegments.kt` — segment data classes (hashtag, url, mention, invoice, etc.) that the parser emits.
- `Patterns.kt` — the regex bank. Single source of truth for URL, hashtag, mention, email, invoice, cashu, nostr-URI patterns. Prefer adding a case here to writing a one-off regex at a call site.
- `UrlParser.kt` — URL extraction + validation; used to pull URLs out of free-form text before the parser classifies them.
- `GalleryParser.kt` — builds
MediaGallerygroupings from consecutive media URLs in a note. - `MediaContentModels.kt` — the rendering contracts:
MediaUrlImage,MediaUrlVideo— plain HTTP(S) media with optional NIP-92 metadata.EncryptedMediaUrlImage,EncryptedMediaUrlVideo— for encrypted/blossom-gated media.MediaLocalImage,MediaLocalVideo— for drafts / not-yet-uploaded media.- `Base64Image.kt` — inline base64 data URI support.
- `ExpandableTextCutOffCalculator.kt` — decides where to truncate long content for "Show more" fold points.
How a Note Becomes Rendered UI
1. Raw content: String arrives (from an Event). 2. RichTextParser scans with patterns, extracts URLs, nostr IDs, hashtags, mentions, invoices, cashu tokens. 3. URLs are classified against imeta tags (NIP-92) so media gets correct dimensions, mime type, blurhash. 4. GalleryParser groups adjacent media into a single MediaGallery segment. 5. The composable layer (elsewhere in commons/compose/ and amethyst/desktop UI) walks the segment list and renders each with the appropriate composable (RenderMarkdown, NoteQuoteBody, ClickableUrl, etc.).
Typical Reuse
// inside a composable
val state = remember(note, imetaTags) {
CachedRichTextParser.parseReturningNullable(content, imetaTags, callbackUri)
}
state?.paragraphs?.forEach { paragraph ->
paragraph.words.forEach { segment ->
when (segment) {
is UrlSegment -> ClickableUrl(segment)
is HashtagSegment -> HashtagChip(segment)
is NostrRefSegment -> NoteCompose(segment.entity)
is ImageSegment -> ZoomableMedia(segment.media)
// ...
}
}
}On Android there's amethyst/.../service/CachedRichTextParser.kt which caches parser output per content — re-parsing the same note on every recomposition is expensive, so always parse behind a cache.
NIP-92 imeta Enrichment
imeta tags attached to an event carry structured metadata for each media URL: url, m (mime), dim, blurhash, x (sha256), size. RichTextParser maps these into MediaUrlImage / MediaUrlVideo so the renderer can reserve correct aspect ratio and show a blurhash placeholder before the image loads. Reference: nip-catalog.md.
Gotchas
- Don't parse on every recomposition. Use
CachedRichTextParser(Android) orremember(content, imeta) { … }for commonMain. - Regexes live in `Patterns.kt`. If you're writing a new regex for URLs/mentions/hashtags in a UI file, move it to
Patterns.ktinstead. - Segments are `@Immutable` data classes — safe to pass to Compose without triggering recomposition spam.
- Encrypted media is a separate class (
EncryptedMediaUrl*). If you handleMediaUrlImagebut not its encrypted sibling, blossom/NIP-17 gated media silently falls through. - `GalleryParser` groups across whitespace-only-lines between URLs. Changing its grouping rules breaks layout in many note screens.
Related
nostr-expert/references/nip-catalog.md— NIP-92 (imeta) spec locationcompose-expert/references/shared-composables-catalog.md— which composables consume which segment types
Shared Composables Catalog
This catalog documents shared UI components in commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/.
Directory Structure
commons/src/commonMain/kotlin/.../commons/ui/
├── components/ # Reusable UI components
├── screens/ # Screen-level composables
├── theme/ # Theming and styling
└── feed/ # Feed-specific componentsComponents (ui/components/)
State Visualization
LoadingState - Centered loading indicator with message
@Composable
fun LoadingState(message: String, modifier: Modifier = Modifier)- Use for: Async operations, data fetching
- Pattern: fillMaxSize, centered Column, CircularProgressIndicator
- Works on: Android, Desktop
EmptyState - Centered empty state with optional refresh
@Composable
fun EmptyState(
title: String,
modifier: Modifier = Modifier,
description: String? = null,
onRefresh: (() -> Unit)? = null,
refreshLabel: String = "Refresh"
)- Use for: Empty lists, no data scenarios
- Pattern: Centered Column, optional OutlinedButton
- Works on: Android, Desktop
ErrorState - Centered error message with retry
@Composable
fun ErrorState(
message: String,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
retryLabel: String = "Try Again"
)- Use for: Error handling, failed operations
- Pattern: error color, optional Button
- Works on: Android, Desktop
Feed-Specific States
FeedEmptyState - Pre-configured empty state for feeds
@Composable
fun FeedEmptyState(
modifier: Modifier = Modifier,
title: String = "Feed is empty",
onRefresh: (() -> Unit)? = null
)FeedErrorState - Pre-configured error state for feeds
@Composable
fun FeedErrorState(
errorMessage: String,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null
)Action Buttons
Shared Constants:
val ActionButtonShape = RoundedCornerShape(20.dp)
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)AddButton - Consistent "Add" action button
@Composable
fun AddButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
text: String = "Add",
enabled: Boolean = true
)- Pattern: OutlinedButton with consistent shape/padding
- Works on: Android, Desktop
RemoveButton - Consistent "Remove" action button
@Composable
fun RemoveButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
text: String = "Remove",
enabled: Boolean = true
)Custom Images
RobohashImage - Deterministic avatar generation
@Composable
fun RobohashImage(
robot: String, // Seed (e.g., pubkey)
modifier: Modifier = Modifier,
contentDescription: String? = null,
loadRobohash: Boolean = true
)
// Overload with more options
@Composable
fun RobohashImage(
robot: String,
modifier: Modifier = Modifier,
contentDescription: String? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
colorFilter: ColorFilter? = null,
loadRobohash: Boolean = true
)- Use for: User avatars, deterministic graphics
- Pattern: Uses CachedRobohash.get(), isLightTheme() detection
- Fallback: Icons.Default.Face
- Works on: Android, Desktop (pure ImageVector)
Theme Detection Helper:
@Composable
private fun isLightTheme(): Boolean {
val background = MaterialTheme.colorScheme.background
return (background.red + background.green + background.blue) / 3 > 0.5f
}Feed Components (ui/feed/)
FeedHeader
FeedHeader - Screen header with title and relay status
@Composable
fun FeedHeader(
title: String,
connectedRelayCount: Int,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
)- Pattern: Row with SpaceBetween, title + RelayStatusIndicator
- Works on: Android, Desktop
RelayStatusIndicator - Compact relay connection indicator
@Composable
fun RelayStatusIndicator(
connectedCount: Int,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
)- Pattern: Status icon + count text + refresh button
- Colors: RelayStatusColors.{Disconnected, Connecting, Connected}
- Visual cues: Check icon (connected), Close icon (disconnected)
Screens (ui/screens/)
Placeholder Pattern
PlaceholderScreen - Generic placeholder
@Composable
fun PlaceholderScreen(
title: String,
description: String,
modifier: Modifier = Modifier
)- Pattern: Column with title (headlineMedium) + description
- Use for: Unimplemented screens, coming soon features
Specific Placeholders:
SearchPlaceholder()- Search screenMessagesPlaceholder()- DMs screenNotificationsPlaceholder()- Notifications screen
Pattern: Specific implementations wrap PlaceholderScreen with preset text.
Custom Icons (robohash/parts/)
ImageVector Builder Pattern
Amethyst uses a custom DSL for building ImageVector assets:
@Composable
fun Face0C3po() {
Image(
painter = rememberVectorPainter(
roboBuilder {
face0C3po(SolidColor(Color.Blue), this)
}
),
contentDescription = ""
)
}
fun face0C3po(fgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
// ...
}
private val pathData1 = PathData {
moveTo(144.5f, 87.5f)
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
// ... path commands
}roboBuilder - Custom ImageVector.Builder DSL
- Located in:
commons/robohash/ - Pattern: Builder-based, composable paths
- Parts: Face, Eyes, Mouth, Body, Accessory (0-9 variants each)
- Colors: Dynamic (fgColor parameter) + Black constants
CachedRobohash
CachedRobohash.get(seed: String, isLight: Boolean): ImageVector- Deterministic: Same seed → same avatar
- Theme-aware: Different colors for light/dark
- Cached: Performance optimization
- Pure ImageVector: Works on all platforms
Sharing Guidelines
Always Share
- State visualization (Loading, Empty, Error)
- Action buttons with consistent styling
- Generic placeholders
- Custom ImageVector icons
- Material3 themed components
- Theme utilities (isLightTheme)
Platform-Specific (Delegate to Experts)
- Navigation structure (android-expert, desktop-expert)
- Screen layouts and scaffolds
- Platform system integrations
- Gesture handling specifics
Decision Framework
1. Can it use Material3 primitives? → Share 2. Does it need platform system APIs? → Platform-specific 3. Is it a visual component without navigation? → Share 4. Does it require platform UX patterns? → Ask platform expert
Material3 Usage
All shared composables use Material3:
MaterialTheme.colorScheme.*for colorsMaterialTheme.typography.*for text stylesOutlinedButton,Button,IconButtonfor actionsCircularProgressIndicatorfor loadingIcon,Imagefor visuals
This ensures consistent theming across Android and Desktop.
Compose State Management Patterns
Visual guide to state management in Compose Multiplatform. For Kotlin-specific patterns (StateFlow, sealed classes), see kotlin-expert skill.
Core State Functions
remember
Cache values across recompositions:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}When to use: Simple UI state (toggles, counters, text input) Visual pattern: Button press → state changes → UI updates
derivedStateOf
Compute state from other state, recompose only when result changes:
@Composable
fun ScrollToTopButton(listState: LazyListState) {
// Only recomposes when showButton value 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)
}
}
}When to use: Input state changes frequently, but derived result changes rarely Visual pattern: Scroll position (0, 1, 2...) → boolean (show/hide) → FAB visibility Performance: Prevents recomposition on every scroll event
produceState
Convert non-Compose state into 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!!)
}
}When to use: Convert Flow, LiveData, callbacks into Compose state Visual pattern: Async operation → state updates → UI reflects changes Lifecycle: Coroutine cancelled when composable leaves composition
State Hoisting Pattern
Move state up to make composables reusable and testable:
Before (Stateful)
@Composable
fun SearchBar() {
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search...") }
)
}❌ Hard to test, can't control state externally
After (Stateless)
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search...") },
modifier = modifier
)
}
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
Column {
SearchBar(query = query, onQueryChange = { query = it })
SearchResults(query = query)
}
}✅ Reusable, testable, state controlled by parent
Hoisting principle: State goes up, events go down
- State:
query: String(read-only) - Events:
onQueryChange: (String) -> Unit(write-only)
Amethyst State Patterns
Theme-Aware State
@Composable
private fun isLightTheme(): Boolean {
val background = MaterialTheme.colorScheme.background
return (background.red + background.green + background.blue) / 3 > 0.5f
}
@Composable
fun ThemedContent() {
val isDark = !isLightTheme()
// Adjust visuals based on theme
val iconTint = if (isDark) Color.White else Color.Black
}Pattern: Derive state from MaterialTheme Visual: Component adapts to light/dark theme automatically
Relay Status State
@Composable
fun RelayStatusIndicator(
connectedCount: Int,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
) {
val statusColor = when {
connectedCount == 0 -> RelayStatusColors.Disconnected
connectedCount < 3 -> RelayStatusColors.Connecting
else -> RelayStatusColors.Connected
}
Icon(
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
tint = statusColor
)
}Pattern: Visual state derived from domain state Visual mapping:
- 0 relays → Red + X icon
- 1-2 relays → Yellow + Check icon
- 3+ relays → Green + Check icon
Loading/Empty/Error States
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is UiState.Loading -> LoadingState("Loading feed...")
is UiState.Empty -> FeedEmptyState(onRefresh = { viewModel.refresh() })
is UiState.Error -> FeedErrorState(
errorMessage = uiState.message,
onRetry = { viewModel.retry() }
)
is UiState.Success -> LazyColumn {
items(uiState.items) { FeedItem(it) }
}
}
}Pattern: Sealed class → visual state component Components:
LoadingState- Progress indicatorEmptyState- Empty message + refreshErrorState- Error message + retry- Success - Actual content
Common Patterns
Toggle State
var isExpanded by remember { mutableStateOf(false) }
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...")
}List State with Actions
var items by remember { mutableStateOf(listOf("Item 1", "Item 2")) }
Column {
AddButton(onClick = {
items = items + "Item ${items.size + 1}"
})
items.forEachIndexed { index, item ->
Row {
Text(item)
RemoveButton(onClick = {
items = items.filterIndexed { i, _ -> i != index }
})
}
}
}TextField State
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it },
label = { Text("Enter text") }
)Performance Patterns
Avoid Unnecessary Recomposition
// ❌ Bad: Recomposes on every scroll position change
@Composable
fun BadScrollButton(scrollState: ScrollState) {
if (scrollState.value > 100) { // scrollState.value changes constantly
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
}
}
// ✅ Good: Only recomposes when visibility changes
@Composable
fun GoodScrollButton(scrollState: ScrollState) {
val showButton by remember {
derivedStateOf { scrollState.value > 100 }
}
if (showButton) {
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
}
}Stable Parameters
Use @Immutable data classes (see kotlin-expert) to prevent recomposition:
@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)
}
}Integration with Kotlin State
For ViewModel state, Flow, StateFlow → See kotlin-expert skill
Common integration pattern:
// ViewModel (Kotlin state)
class FeedViewModel {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
}
// Composable (Compose state)
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val uiState by viewModel.uiState.collectAsState()
// Use uiState to render UI
}Quick Reference
| Function | Use Case | Recomposes When |
|---|---|---|
remember { mutableStateOf() } | Local UI state | State value changes |
derivedStateOf { } | Computed state | Derived result changes |
produceState { } | Async/Flow → State | Async operation updates value |
collectAsState() | Flow → State | Flow emits new value |
| State hoisting | Reusable components | Parent passes new state |
Sources
State management patterns based on:
- State and Jetpack Compose - Android Developers
- When should I use derivedStateOf?
- Advanced State and Side Effects
- AmethystMultiplatform codebase patterns (2025)
#!/bin/bash
# Find all @Composable functions in the codebase
set -e
# Default to current directory if no path provided
SEARCH_PATH="${1:-.}"
echo "Searching for @Composable functions in: $SEARCH_PATH"
echo "================================================"
echo ""
# Find all @Composable functions with file paths and line numbers
grep -r -n "@Composable" "$SEARCH_PATH" \
--include="*.kt" \
--exclude-dir=build \
--exclude-dir=.gradle \
| while IFS=: read -r file line content; do
# Extract function name if possible
if [[ $content =~ fun[[:space:]]+([a-zA-Z0-9_]+) ]]; then
func_name="${BASH_REMATCH[1]}"
echo "$file:$line - $func_name"
else
echo "$file:$line"
fi
done
echo ""
echo "Total @Composable functions found:"
grep -r "@Composable" "$SEARCH_PATH" \
--include="*.kt" \
--exclude-dir=build \
--exclude-dir=.gradle \
| wc -l
Related skills
FAQ
What platforms does compose-expert target?
compose-expert targets Compose Multiplatform shared UI for Android and Desktop, helping developers place composables in commonMain or platform-specific source sets while applying Material3 and state patterns.
Does compose-expert cover navigation setup?
compose-expert focuses on visual UI, state, theming, and recomposition patterns and delegates navigation guidance to android-expert and desktop-expert skills in the Amethyst project.
Is Compose Expert safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.