diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index ab4dcad6e3..6b51cdab0a 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -29,7 +29,7 @@ Create 8 hybrid domain skills combining general expertise with AmethystMultiplat --- -### 2. gradle-expert +### 2. gradle-expert ✅ COMPLETED **Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting **SKILL.md sections:** @@ -44,11 +44,15 @@ Create 8 hybrid domain skills combining general expertise with AmethystMultiplat **Bundled resources:** - `references/build-commands.md` - Common gradle tasks - `references/dependency-graph.md` - Module visualization +- `references/version-catalog-guide.md` - Version catalog patterns +- `references/common-errors.md` - Troubleshooting guide - `scripts/analyze-build-time.sh` - Performance report - `scripts/fix-dependency-conflicts.sh` - Conflict patterns **Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts). +**Status:** ✅ SKILL.md (549 lines) + 4 references + 2 scripts created at `.claude/skills/gradle-expert/` + --- ### 3. kotlin-expert ✅ DRAFT COMPLETE @@ -86,25 +90,28 @@ Create 8 hybrid domain skills combining general expertise with AmethystMultiplat --- -### 4. compose-expert +### 4. compose-expert ✅ COMPLETED **Focus:** Shared composables, state management, animations, Material3 **SKILL.md sections:** - Shared composables philosophy (100+ already shared in commons/commonMain) -- State management: remember, derivedStateOf, produceState -- Recomposition optimization: @Stable/@Immutable +- State management: remember, derivedStateOf, produceState (visual patterns) +- Recomposition optimization: @Stable/@Immutable (visual usage) - Material3 conventions: theming -- Custom icons: ImageVector builders (38+ icons) +- Custom icons: ImageVector builders (robohash pattern) - Platform differences: Desktop vs Android UI - Performance: lazy lists, image loading +- Decision framework: share by default in commonMain **Bundled resources:** -- `references/shared-composables-catalog.md` - 100+ composables -- `references/state-patterns.md` - State hoisting examples -- `references/icon-assets.md` - Custom icon catalog -- `scripts/find-composables.sh` - Grep @Composable +- `references/shared-composables-catalog.md` - Complete catalog with patterns +- `references/state-patterns.md` - State hoisting, derivedStateOf examples +- `references/icon-assets.md` - ImageVector patterns, roboBuilder DSL +- `scripts/find-composables.sh` - Grep @Composable utility -**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). +**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). Delegates navigation to platform experts, defers Kotlin language details to kotlin-expert. + +**Status:** ✅ SKILL.md (578 lines) + 3 references + 1 script created at `.claude/skills/compose-expert/` --- @@ -206,26 +213,30 @@ Create 8 hybrid domain skills combining general expertise with AmethystMultiplat --- -### 8. nostr-expert +### 8. nostr-expert ✅ COMPLETED **Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns **SKILL.md sections:** - Quartz architecture: package structure by NIP (57 NIPs implemented) -- Event anatomy: IEvent, Event, kinds, tags (verified in TextNoteEvent.kt) -- NIP implementation patterns: Event class, tags, builders -- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent -- Tag patterns: aTag, eTag, pTag builders -- Relay client: WebSocket, subscriptions -- Cryptography: secp256k1, NIP-44 encryption -- Event builders: TagArrayBuilder DSL +- Event anatomy: IEvent, Event, kinds, tags +- EventTemplate & TagArrayBuilder DSL patterns +- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent, Addressable events +- Tag patterns: e-tag, p-tag, a-tag, d-tag with builders +- Threading (NIP-10): reply/root markers +- Cryptography: secp256k1 signing, NIP-44 encryption +- Bech32 encoding: npub, nsec, note, nevent +- Event validation & verification +- Common workflows: publishing, querying, zaps, gift-wrapped DMs **Bundled resources:** -- `references/nip-catalog.md` - All 57 NIPs with code links -- `references/event-hierarchy.md` - Event class diagram -- `references/tag-patterns.md` - Tag builders from nip01Core/tags/ -- `scripts/nip-lookup.sh` - Find NIP implementation files +- `references/nip-catalog.md` - All 57 NIPs with package locations (179 lines) +- `references/event-hierarchy.md` - Event class hierarchy, kind classifications (293 lines) +- `references/tag-patterns.md` - Tag structure, TagArrayBuilder DSL, parsing (251 lines) +- `scripts/nip-lookup.sh` - Find NIP implementations by number or search term -**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation (57 NIPs), code patterns. +**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation patterns (57 NIPs), concrete code examples from codebase. + +**Status:** ✅ SKILL.md (552 lines) + 3 references + 1 script created at `.claude/skills/nostr-expert/` --- @@ -241,10 +252,13 @@ Using skill-creator 10-step methodology per skill: **Per-Skill Implementation:** - kotlin-multiplatform: ✅ COMPLETED -- android-expert: ✅ DRAFT COMPLETE -- desktop-expert: ✅ DRAFT COMPLETE -- kotlin-expert: ✅ DRAFT COMPLETE -- Remaining 4 skills: Pending (gradle-expert, compose-expert, ios-expert, nostr-expert) +- gradle-expert: ✅ COMPLETED +- kotlin-expert: ✅ COMPLETED +- compose-expert: ✅ COMPLETED +- desktop-expert: ✅ COMPLETED +- android-expert: ✅ COMPLETED +- nostr-expert: ✅ COMPLETED +- ios-expert: ⏸️ DEFERRED (iOS not yet implemented in AmethystMultiplatform) ## Critical Files Referenced @@ -267,22 +281,42 @@ Using skill-creator 10-step methodology per skill: 1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference 2. ✅ Completed kotlin-multiplatform skill -3. ✅ Completed android-expert skill (DRAFT) -4. ✅ Completed desktop-expert skill (DRAFT) -5. ⏳ **Next: Pick one of the remaining 5 skills:** - - gradle-expert - Build optimization, dependency resolution - - kotlin-expert - Coroutines, Flow, sealed classes - - compose-expert - Shared composables, state management - - ios-expert - iOS platform patterns, XCFramework - - nostr-expert - Nostr protocol, NIPs, Quartz +3. ✅ Completed gradle-expert skill +4. ✅ Completed kotlin-expert skill +5. ✅ Completed compose-expert skill +6. ✅ Completed desktop-expert skill +7. ✅ Completed android-expert skill +8. ✅ Completed nostr-expert skill +9. ⏸️ Deferred ios-expert (iOS not yet implemented in codebase) -## Current Focus: 3 skills completed +## Current Status: 7/8 Skills Completed -**Completed:** -- kotlin-multiplatform ✅ -- android-expert ✅ -- desktop-expert ✅ +**Completed Skills (Auto-loaded from `.claude/skills/`):** +1. ✅ kotlin-multiplatform (KMP architecture, jvmAndroid pattern, expect/actual) +2. ✅ gradle-expert (Build system, dependencies, version catalog, troubleshooting) +3. ✅ kotlin-expert (Flow state, sealed classes, @Immutable, DSL builders) +4. ✅ compose-expert (Shared composables, state management, Material3, ImageVector) +5. ✅ desktop-expert (Desktop UX, window management, Compose Desktop APIs) +6. ✅ android-expert (Android platform APIs, navigation, permissions) +7. ✅ nostr-expert (Nostr protocol, Quartz implementation, NIPs, events, tags) -**Remaining:** 5 skills +**Deferred:** +- ⏸️ ios-expert (iOS not implemented yet in AmethystMultiplatform) -Ready to start next skill when requested. +## Skill Loading + +**All completed skills are automatically loaded** when this project opens. Skills are auto-discovered from `.claude/skills/` directory. + +To manually verify skills are loaded: +```bash +ls -1 .claude/skills/ +``` + +Should show: +- android-expert/ +- compose-expert/ +- desktop-expert/ +- gradle-expert/ +- kotlin-expert/ +- kotlin-multiplatform/ +- nostr-expert/ diff --git a/.claude/skills/compose-expert/SKILL.md b/.claude/skills/compose-expert/SKILL.md new file mode 100644 index 0000000000..0ba02c0135 --- /dev/null +++ b/.claude/skills/compose-expert/SKILL.md @@ -0,0 +1,577 @@ +--- +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 navigation to android-expert/desktop-expert. 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 `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 + +```kotlin +@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 + +```kotlin +@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 + +```kotlin +@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 + +```kotlin +@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 + +```kotlin +@Composable +fun LoadUserProfile(userId: String): State { + return produceState(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: + +```kotlin +// ❌ 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: + +```kotlin +@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 + +```kotlin +// ✅ 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: + +```kotlin +@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 + +```kotlin +@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 + +```kotlin +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 + +```kotlin +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 + +```kotlin +object CustomIcons { + private val cache = mutableMapOf() + + 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 + +```kotlin +@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) + +```kotlin +@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 + +```kotlin +@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 + +```kotlin +// ❌ 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 + +```kotlin +@Composable +fun FeedList(items: List) { + 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 +- **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 diff --git a/.claude/skills/compose-expert/references/icon-assets.md b/.claude/skills/compose-expert/references/icon-assets.md new file mode 100644 index 0000000000..96b8fea26f --- /dev/null +++ b/.claude/skills/compose-expert/references/icon-assets.md @@ -0,0 +1,365 @@ +# 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: + +```kotlin +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**: +```kotlin +@Composable +fun CustomIcon() { + Image( + painter = rememberVectorPainter( + roboBuilder { + // Add paths here + } + ), + contentDescription = "Custom icon" + ) +} +``` + +### Path Building Pattern + +```kotlin +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**: +- `pathData` variables for path commands +- `addPath()` 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 coordinates +- `moveToRelative` / `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 + +```kotlin +// 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: + +```kotlin +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: + +```kotlin +// 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: + +```kotlin +object CachedRobohash { + private val cache = mutableMapOf, 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**: +```kotlin +@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: + +```kotlin +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: + +```kotlin +val Black = SolidColor(Color.Black) +val White = SolidColor(Color.White) +val Transparent = SolidColor(Color.Transparent) +``` + +## Advanced Techniques + +### Layering + +Build complex icons with multiple layers: + +```kotlin +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 + +```kotlin +// 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 + +```kotlin +// 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: + +```kotlin +@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**: +```kotlin +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 utilities +``` + +### Collection Object +```kotlin +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](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/vector/ImageVector) +- [SVG Path Commands](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths) +- [Material Icons](https://fonts.google.com/icons) +- Robohash implementation: `commons/robohash/` in AmethystMultiplatform diff --git a/.claude/skills/compose-expert/references/shared-composables-catalog.md b/.claude/skills/compose-expert/references/shared-composables-catalog.md new file mode 100644 index 0000000000..19f7938fd6 --- /dev/null +++ b/.claude/skills/compose-expert/references/shared-composables-catalog.md @@ -0,0 +1,281 @@ +# 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 components +``` + +## Components (`ui/components/`) + +### State Visualization + +**LoadingState** - Centered loading indicator with message +```kotlin +@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 +```kotlin +@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 +```kotlin +@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 +```kotlin +@Composable +fun FeedEmptyState( + modifier: Modifier = Modifier, + title: String = "Feed is empty", + onRefresh: (() -> Unit)? = null +) +``` + +**FeedErrorState** - Pre-configured error state for feeds +```kotlin +@Composable +fun FeedErrorState( + errorMessage: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null +) +``` + +### Action Buttons + +**Shared Constants**: +```kotlin +val ActionButtonShape = RoundedCornerShape(20.dp) +val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp) +``` + +**AddButton** - Consistent "Add" action button +```kotlin +@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 +```kotlin +@Composable +fun RemoveButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Remove", + enabled: Boolean = true +) +``` + +### Custom Images + +**RobohashImage** - Deterministic avatar generation +```kotlin +@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**: +```kotlin +@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 +```kotlin +@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 +```kotlin +@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 +```kotlin +@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 screen +- `MessagesPlaceholder()` - DMs screen +- `NotificationsPlaceholder()` - 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: + +```kotlin +@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 + +```kotlin +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 colors +- `MaterialTheme.typography.*` for text styles +- `OutlinedButton`, `Button`, `IconButton` for actions +- `CircularProgressIndicator` for loading +- `Icon`, `Image` for visuals + +This ensures consistent theming across Android and Desktop. diff --git a/.claude/skills/compose-expert/references/state-patterns.md b/.claude/skills/compose-expert/references/state-patterns.md new file mode 100644 index 0000000000..3015f766b6 --- /dev/null +++ b/.claude/skills/compose-expert/references/state-patterns.md @@ -0,0 +1,334 @@ +# 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: + +```kotlin +@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: + +```kotlin +@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: + +```kotlin +@Composable +fun LoadUserProfile(userId: String): State { + return produceState(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) +```kotlin +@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) +```kotlin +@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 + +```kotlin +@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 + +```kotlin +@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 + +```kotlin +@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 indicator +- `EmptyState` - Empty message + refresh +- `ErrorState` - Error message + retry +- Success - Actual content + +## Common Patterns + +### Toggle State +```kotlin +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 +```kotlin +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 +```kotlin +var text by remember { mutableStateOf("") } + +TextField( + value = text, + onValueChange = { text = it }, + label = { Text("Enter text") } +) +``` + +## Performance Patterns + +### Avoid Unnecessary Recomposition +```kotlin +// ❌ 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: + +```kotlin +@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: +```kotlin +// ViewModel (Kotlin state) +class FeedViewModel { + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState: StateFlow = _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](https://developer.android.com/develop/ui/compose/state) +- [When should I use derivedStateOf?](https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b) +- [Advanced State and Side Effects](https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects) +- AmethystMultiplatform codebase patterns (2025) diff --git a/.claude/skills/compose-expert/scripts/find-composables.sh b/.claude/skills/compose-expert/scripts/find-composables.sh new file mode 100755 index 0000000000..436a3cefeb --- /dev/null +++ b/.claude/skills/compose-expert/scripts/find-composables.sh @@ -0,0 +1,34 @@ +#!/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 diff --git a/.claude/skills/gradle-expert/SKILL.md b/.claude/skills/gradle-expert/SKILL.md new file mode 100644 index 0000000000..208caa11cc --- /dev/null +++ b/.claude/skills/gradle-expert/SKILL.md @@ -0,0 +1,548 @@ +--- +name: gradle-expert +description: Build optimization, dependency resolution, and multi-module KMP troubleshooting for AmethystMultiplatform. Use when working with: (1) Gradle build files (build.gradle.kts, settings.gradle), (2) Version catalog (libs.versions.toml), (3) Build errors and dependency conflicts, (4) Module dependencies and source sets, (5) Desktop packaging (DMG/MSI/DEB), (6) Build performance optimization, (7) Proguard/R8 configuration, (8) Common KMP + Android Gradle issues (Compose conflicts, secp256k1 JNI variants, source set problems). +--- + +# Gradle Expert + +Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations. + +## Build Architecture Mental Model + +Think of this project as **4 layers**: + +``` +┌─────────────┬─────────────┐ +│ :amethyst │ :desktopApp │ ← Platform apps (navigation, layouts) +│ (Android) │ (JVM) │ +└──────┬──────┴──────┬──────┘ + │ │ + └──────┬──────┘ + ▼ + ┌─────────────┐ + │ :commons │ ← Shared UI (KMP with jvmAndroid) + │ (KMP UI) │ + └──────┬──────┘ + ▼ + ┌─────────────┐ + │ :quartz │ ← Core library (KMP: Android/JVM/iOS) + │(KMP Library)│ + └─────────────┘ +``` + +**Key insight:** Dependencies flow DOWN. Lower modules never depend on upper modules. This enables code sharing without circular dependencies. + +**The jvmAndroid pattern:** Unique to this project. A custom source set between commonMain and {androidMain, jvmMain} for JVM-specific code shared by Android and Desktop. Not standard KMP, but critical for this architecture. + +## Version Catalog Philosophy + +All dependencies centralized in `gradle/libs.versions.toml`. Think "single source of truth." + +**Pattern:** +```toml +[versions] +kotlin = "2.3.0" + +[libraries] +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } + +[plugins] +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +``` + +**Usage:** +```kotlin +dependencies { + implementation(libs.okhttp) // Type-safe, IDE-autocompleted +} +``` + +**Critical alignments:** +- **Kotlin ecosystem:** All Kotlin plugins MUST share same version +- **Compose ecosystem:** Compose Multiplatform version → Kotlin version (check compatibility matrix) +- **secp256k1 variants:** All three variants (common, jni-android, jni-jvm) MUST share same version + +See [references/version-catalog-guide.md](references/version-catalog-guide.md) for comprehensive patterns. + +## Common Build Tasks + +### Quick Reference + +```bash +# Full builds +./gradlew build # All modules +./gradlew clean build # Clean build + +# Desktop +./gradlew :desktopApp:run # Run desktop app +./gradlew :desktopApp:packageDmg # macOS package + +# Module-specific +./gradlew :quartz:build # KMP library only +./gradlew :commons:build # Shared UI only + +# Analysis +./gradlew dependencies # Dependency tree +./gradlew build --scan # Online diagnostics +``` + +See [references/build-commands.md](references/build-commands.md) for comprehensive command reference. + +## Module Structure & Dependencies + +### Dependency Flow + +**Desktop build chain:** +``` +:desktopApp → :commons (jvmMain) → :quartz (jvmMain → jvmAndroid → commonMain) +``` + +**Android build chain:** +``` +:amethyst → :commons (androidMain) → :quartz (androidMain → jvmAndroid → commonMain) +``` + +**Key source set pattern (quartz & commons):** +``` +commonMain # Truly cross-platform code + │ + ├─ jvmAndroid # JVM-specific, shared by Android + Desktop + │ ├─ androidMain + │ └─ jvmMain + │ + └─ iosMain # iOS-specific (quartz only) +``` + +**Dependency config types:** +- Use `api` when types appear in module's public API or expect/actual declarations +- Use `implementation` for internal implementation details +- Example: quartz exposes secp256k1 (`api`), but hides okhttp (`implementation`) + +See [references/dependency-graph.md](references/dependency-graph.md) for module visualization and transitive dependency flow. + +## Critical Dependency Patterns + +### 1. secp256k1 (Crypto Library) + +**The problem:** KMP library with platform-specific JNI bindings. Wrong variant = runtime crash. + +**Pattern:** +```kotlin +// commonMain - API only +api(libs.secp256k1.kmp.common) + +// androidMain - Android JNI +api(libs.secp256k1.kmp.jni.android) + +// jvmMain - Desktop JVM JNI +implementation(libs.secp256k1.kmp.jni.jvm) +``` + +**Why api in androidMain?** Types leak to consumers (:amethyst). + +**Common error:** Desktop using jni-android variant → `UnsatisfiedLinkError: no secp256k1jni in java.library.path` + +**Fix:** Check source set dependencies. jvmMain must use jni-jvm, never jni-android. + +### 2. JNA (for LibSodium Encryption) + +**The problem:** Android needs AAR packaging, JVM needs JAR. Same library, different artifact types. + +**Pattern:** +```kotlin +// androidMain +implementation("com.goterl:lazysodium-android:5.2.0@aar") // @aar explicit +implementation("net.java.dev.jna:jna:5.18.1@aar") + +// jvmMain +implementation(libs.lazysodium.java) // JAR implicit +implementation(libs.jna) +``` + +**Critical:** Never put JNA in jvmAndroid or commonMain. Platform-specific packaging only. + +### 3. Compose Versions + +**The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes. + +**Current project config:** +```toml +composeMultiplatform = "1.9.3" # Plugin + runtime +composeBom = "2025.12.01" # AndroidX Compose BOM +kotlin = "2.3.0" +``` + +**Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html + +**In KMP modules (quartz, commons):** +```kotlin +// ✅ Use Compose Multiplatform +implementation(compose.ui) +implementation(compose.material3) + +// ❌ DON'T use AndroidX BOM in KMP modules +// implementation(libs.androidx.compose.bom) +``` + +**In Android-only modules (amethyst):** +```kotlin +// Can use AndroidX BOM +val composeBom = platform(libs.androidx.compose.bom) +implementation(composeBom) +``` + +## Desktop Packaging Basics + +**TargetFormat options:** +```kotlin +// In desktopApp/build.gradle.kts +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")) + } +} +``` + +**Package tasks:** +```bash +./gradlew :desktopApp:packageDmg # macOS +./gradlew :desktopApp:packageMsi # Windows +./gradlew :desktopApp:packageDeb # Linux +``` + +**Output locations:** +- macOS: `desktopApp/build/compose/binaries/main/dmg/` +- Windows: `desktopApp/build/compose/binaries/main/msi/` +- Linux: `desktopApp/build/compose/binaries/main/deb/` + +**Icon requirements:** +- macOS: `.icns` (multi-resolution: 512, 256, 128, 32) +- Windows: `.ico` (256, 128, 64, 32, 16) +- Linux: `.png` (512x512) + +**Common issues:** +- Main class not found → Verify `mainClass = "...MainKt"` (Kotlin adds `Kt` suffix) +- Native libs missing → Ensure secp256k1-kmp-jni-jvm in dependencies +- Icon not found → Check file exists at path, use absolute path if needed + +## Build Performance Optimization + +**Add to `gradle.properties`:** +```properties +# Daemon (faster subsequent builds) +org.gradle.daemon=true +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g + +# Parallel execution (multi-module speedup) +org.gradle.parallel=true +org.gradle.workers.max=8 + +# Caching (incremental builds) +org.gradle.caching=true +org.gradle.configuration-cache=true + +# Kotlin daemon +kotlin.incremental=true +kotlin.daemon.jvmargs=-Xmx2g +``` + +**Impact:** Typically 30-50% faster builds after first run. + +**Measure impact:** +```bash +./gradlew clean build --profile +# Report: build/reports/profile/profile-.html +``` + +**When to clean build:** +- After changing version catalog +- After adding/removing source sets +- When seeing unexplained errors + +**When NOT to clean:** +- Regular development iteration +- Small code changes +- Incremental compilation works fine + +Use script: `scripts/analyze-build-time.sh` for automated profiling. + +## Troubleshooting: Practical Patterns + +### Pattern 1: Version Conflict + +**Symptom:** `Duplicate class` or `NoSuchMethodError` + +**Diagnosis:** +```bash +./gradlew dependencyInsight --dependency +``` + +**Fix options:** +1. Align versions in libs.versions.toml (preferred) +2. Force resolution: +```kotlin +configurations.all { + resolutionStrategy { + force(libs.okhttp.get().toString()) + } +} +``` + +### Pattern 2: Source Set Issues + +**Symptom:** `Unresolved reference` to JVM library in shared code + +**Diagnosis:** Check source set hierarchy. JVM-only libs (jackson, okhttp) can't be in commonMain. + +**Fix:** Move to jvmAndroid or platform-specific source set. + +```kotlin +// ❌ Wrong +commonMain { + dependencies { + implementation(libs.jackson.module.kotlin) // JVM-only! + } +} + +// ✅ Correct +val jvmAndroid = create("jvmAndroid") { + dependsOn(commonMain.get()) + dependencies { + api(libs.jackson.module.kotlin) // JVM code, shared by Android + Desktop + } +} +``` + +### Pattern 3: Proguard Stripping Native Libs + +**Symptom:** `NoClassDefFoundError` for secp256k1, JNA, or LibSodium in release builds + +**Fix:** Update proguard rules in `quartz/proguard-rules.pro`: +```proguard +# Native libraries +-keep class fr.acinq.secp256k1.** { *; } +-keep class com.goterl.lazysodium.** { *; } +-keep class com.sun.jna.** { *; } + +# Jackson (reflection-based) +-keep class com.vitorpamplona.quartz.** { *; } +-keepattributes *Annotation* +-keepattributes Signature +``` + +### Pattern 4: Compose Compiler Mismatch + +**Symptom:** `IllegalStateException: Version mismatch: runtime 1.10.0 but compiler 1.9.0` + +**Fix:** Update Compose Multiplatform version in libs.versions.toml to match Kotlin version compatibility. + +Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html + +### Pattern 5: Wrong JVM Target + +**Symptom:** `Unsupported class file major version 65` + +**Fix:** Ensure Java 21 everywhere: +```bash +# Check current Java +java -version # Should show 21 + +# Set JAVA_HOME +export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home + +# Stop Gradle daemon to pick up new Java +./gradlew --stop +``` + +Verify all build files use JVM 21: +```kotlin +kotlin { + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } +} + +android { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} +``` + +## Comprehensive Error Guide + +For detailed troubleshooting of specific errors, see [references/common-errors.md](references/common-errors.md). Covers: +- Compose version conflicts +- secp256k1 JNI errors +- Source set dependency issues +- Proguard/R8 problems +- Desktop packaging errors +- Kotlin compilation errors +- Dependency resolution failures +- JVM/JDK version issues + +Each error includes: symptom, cause, solution, verification steps. + +## Quick Diagnostic Commands + +```bash +# Check dependencies for specific module +./gradlew :quartz:dependencies + +# Find specific library in dependency tree +./gradlew dependencyInsight --dependency okhttp + +# Build with detailed logging +./gradlew build --info + +# Generate interactive build scan (best diagnostics) +./gradlew build --scan + +# Profile build performance +./gradlew clean build --profile + +# Stop all Gradle daemons (fresh start) +./gradlew --stop + +# Check Gradle version +./gradlew --version +``` + +## Scripts & References + +### Diagnostic Scripts +- `scripts/analyze-build-time.sh` - Profile build performance, generate optimization report +- `scripts/fix-dependency-conflicts.sh` - Diagnose common dependency conflicts, suggest fixes + +### Reference Docs +- `references/build-commands.md` - Comprehensive command reference for all tasks +- `references/dependency-graph.md` - Module dependencies, source set hierarchy, transitive deps +- `references/version-catalog-guide.md` - Version catalog patterns, usage, best practices +- `references/common-errors.md` - Troubleshooting guide for frequent build issues + +## Workflow Examples + +### Example 1: Adding New Dependency + +**Task:** Add kotlinx.datetime to quartz + +**Steps:** +1. **Update version catalog** (gradle/libs.versions.toml): +```toml +[versions] +kotlinxDatetime = "0.6.0" + +[libraries] +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } +``` + +2. **Add to build file** (quartz/build.gradle.kts): +```kotlin +sourceSets { + commonMain { + dependencies { + implementation(libs.kotlinx.datetime) // KMP library, goes in commonMain + } + } +} +``` + +3. **Sync & verify:** +```bash +./gradlew :quartz:dependencies | grep datetime +``` + +### Example 2: Fixing secp256k1 Error on Desktop + +**Error:** `UnsatisfiedLinkError: no secp256k1jni in java.library.path` when running desktop app + +**Diagnosis:** +```bash +./gradlew :desktopApp:dependencies --configuration runtimeClasspath | grep secp256k1 +# Shows: secp256k1-kmp-jni-android ← WRONG! +``` + +**Fix:** +```kotlin +// In quartz/build.gradle.kts +jvmMain { + dependencies { + // Change from: + // implementation(libs.secp256k1.kmp.jni.android) ❌ + + // To: + implementation(libs.secp256k1.kmp.jni.jvm) // ✅ + } +} +``` + +**Verify:** +```bash +./gradlew :desktopApp:dependencies --configuration runtimeClasspath | grep secp256k1 +# Now shows: secp256k1-kmp-jni-jvm ✅ + +./gradlew :desktopApp:run # Should work +``` + +### Example 3: Optimizing Build Time + +**Current:** Clean build takes 5 minutes + +**Steps:** +1. **Baseline measurement:** +```bash +./gradlew clean build --profile +# Check: build/reports/profile/profile-*.html +``` + +2. **Add optimizations** to gradle.properties: +```properties +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.jvmargs=-Xmx4g +kotlin.incremental=true +``` + +3. **Re-measure:** +```bash +./gradlew clean build --profile +``` + +**Expected improvement:** 30-50% faster on subsequent builds (incremental builds much faster). + +## Delegation Patterns + +**When to delegate to other skills:** +- **Source set architecture** (jvmAndroid pattern, expect/actual) → Use `kotlin-multiplatform` skill +- **Compose UI issues** (composables, state management) → Use `compose-expert` skill (when available) +- **Kotlin language issues** (Flow, sealed classes, DSLs) → Use `kotlin-expert` skill +- **Desktop-specific features** (Window management, MenuBar, tray) → Use `desktop-expert` skill + +**This skill handles:** Build system, dependencies, versioning, module structure, packaging, performance. + +## Core Principles for This Build System + +1. **Centralize versions:** Never hardcode versions in build.gradle.kts. Always use libs.versions.toml. + +2. **Respect source set hierarchy:** Dependencies flow downward. jvmAndroid depends on commonMain, never the reverse. + +3. **Platform-specific variants matter:** secp256k1, JNA must use correct variant per platform. Check when errors occur. + +4. **Clean builds are expensive:** Use incremental compilation. Only clean when truly needed (source set changes, version updates). + +5. **Compose alignment is critical:** Compose Multiplatform version must match Kotlin version. Check compatibility matrix. + +6. **Proguard for native libs:** All JNI libraries need explicit `-keep` rules in release builds. + +7. **Java 21 everywhere:** All modules, all targets, consistent JVM version. diff --git a/.claude/skills/gradle-expert/references/build-commands.md b/.claude/skills/gradle-expert/references/build-commands.md new file mode 100644 index 0000000000..63cde3dfb7 --- /dev/null +++ b/.claude/skills/gradle-expert/references/build-commands.md @@ -0,0 +1,214 @@ +# Build Commands Reference + +## Table of Contents +- [Core Build Tasks](#core-build-tasks) +- [Module-Specific Builds](#module-specific-builds) +- [Desktop Tasks](#desktop-tasks) +- [Android Tasks](#android-tasks) +- [Testing](#testing) +- [Analysis & Diagnostics](#analysis--diagnostics) +- [Performance Optimization](#performance-optimization) + +## Core Build Tasks + +### Full Project Build +```bash +./gradlew build # Build all modules +./gradlew clean build # Clean build +./gradlew assemble # Build without tests +``` + +### Incremental Builds +```bash +./gradlew :quartz:build # Build only quartz module +./gradlew :commons:build # Build only commons module +./gradlew :desktopApp:build # Build only desktop app +``` + +## Module-Specific Builds + +### Quartz (KMP Library) +```bash +./gradlew :quartz:build # All targets +./gradlew :quartz:compileKotlinJvm # JVM target only +./gradlew :quartz:compileDebugKotlinAndroid # Android target only +./gradlew :quartz:linkDebugFrameworkIosArm64 # iOS framework +./gradlew :quartz:publishToMavenLocal # Publish locally +``` + +### Commons (Shared UI) +```bash +./gradlew :commons:build # All targets +./gradlew :commons:compileKotlinJvm # Desktop target +./gradlew :commons:compileDebugKotlinAndroid # Android target +``` + +## Desktop Tasks + +### Run Desktop App +```bash +./gradlew :desktopApp:run # Run desktop app +./gradlew :desktopApp:runDistributable # Run packaged version +``` + +### Package Desktop App +```bash +./gradlew :desktopApp:createDistributable # Create runnable package +./gradlew :desktopApp:packageDmg # macOS DMG +./gradlew :desktopApp:packageMsi # Windows MSI +./gradlew :desktopApp:packageDeb # Linux DEB +``` + +### Distribution Location +- macOS: `desktopApp/build/compose/binaries/main/dmg/` +- Windows: `desktopApp/build/compose/binaries/main/msi/` +- Linux: `desktopApp/build/compose/binaries/main/deb/` + +## Android Tasks + +### Compile & Assemble +```bash +./gradlew :amethyst:assembleDebug # Debug APK +./gradlew :amethyst:assembleRelease # Release APK +./gradlew :amethyst:bundleRelease # Release AAB +``` + +### Install & Run +```bash +./gradlew :amethyst:installDebug # Install debug on device +adb shell am start -n com.vitorpamplona.amethyst/.MainActivity +``` + +### Proguard/R8 +```bash +./gradlew :quartz:minifyReleaseWithR8 # Test R8 minification +``` + +## Testing + +### Unit Tests +```bash +./gradlew test # All unit tests +./gradlew :quartz:jvmTest # JVM unit tests +./gradlew :quartz:testDebugUnitTest # Android unit tests +./gradlew :commons:test # Commons tests +``` + +### Android Instrumented Tests +```bash +./gradlew :quartz:connectedAndroidTest # Requires device/emulator +``` + +### Test Reports +```bash +# Reports location: /build/reports/tests/ +open quartz/build/reports/tests/jvmTest/index.html +``` + +## Analysis & Diagnostics + +### Dependency Analysis +```bash +./gradlew dependencies # All dependencies +./gradlew :quartz:dependencies # Quartz dependencies +./gradlew dependencyInsight --dependency okhttp # Specific dependency +``` + +### Build Scan +```bash +./gradlew build --scan # Upload to scans.gradle.com +``` + +### Performance Profiling +```bash +./gradlew build --profile # Generate profile report +# Report: build/reports/profile/profile-.html +``` + +### Task Dependencies +```bash +./gradlew :desktopApp:run --dry-run # Show task graph +./gradlew :desktopApp:dependencies --scan # Visualize dependencies +``` + +## Performance Optimization + +### Configuration Cache +```bash +./gradlew build --configuration-cache # Enable config cache +./gradlew build --configuration-cache-problems=warn +``` + +### Build Cache +```bash +./gradlew build --build-cache # Enable build cache +./gradlew cleanBuildCache # Clear build cache +``` + +### Parallel Execution +```bash +./gradlew build --parallel --max-workers=8 # Parallel with 8 workers +``` + +### Daemon Management +```bash +./gradlew --stop # Stop Gradle daemon +./gradlew --status # Daemon status +``` + +### Incremental Compilation +```bash +# Already enabled by default in Kotlin, but can verify: +./gradlew :quartz:compileKotlinJvm --info | grep "Incremental" +``` + +## gradle.properties Optimizations + +Add to `gradle.properties` for faster builds: + +```properties +# Daemon +org.gradle.daemon=true +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g + +# Parallel +org.gradle.parallel=true +org.gradle.workers.max=8 + +# Caching +org.gradle.caching=true +org.gradle.configuration-cache=true + +# Kotlin +kotlin.incremental=true +kotlin.daemon.jvmargs=-Xmx2g +``` + +## Common Workflows + +### Full Desktop Build & Run +```bash +./gradlew :desktopApp:clean :desktopApp:run +``` + +### Quick Desktop Iteration +```bash +# No clean - incremental compilation +./gradlew :desktopApp:run +``` + +### Android Release Build +```bash +./gradlew :amethyst:clean :amethyst:bundleRelease +``` + +### Test All KMP Targets +```bash +./gradlew :quartz:test :quartz:testDebugUnitTest +``` + +### Publish Quartz Locally for Testing +```bash +./gradlew :quartz:publishToMavenLocal +# Then update version in consumer project to test +``` diff --git a/.claude/skills/gradle-expert/references/common-errors.md b/.claude/skills/gradle-expert/references/common-errors.md new file mode 100644 index 0000000000..a97f59d64c --- /dev/null +++ b/.claude/skills/gradle-expert/references/common-errors.md @@ -0,0 +1,643 @@ +# Common Build Errors & Solutions + +## Table of Contents +- [Compose Version Conflicts](#compose-version-conflicts) +- [secp256k1 JNI Errors](#secp256k1-jni-errors) +- [Source Set Dependency Issues](#source-set-dependency-issues) +- [Proguard/R8 Issues](#proguardr8-issues) +- [Desktop Packaging Errors](#desktop-packaging-errors) +- [Kotlin Compilation Errors](#kotlin-compilation-errors) +- [Dependency Resolution Failures](#dependency-resolution-failures) +- [JVM/JDK Version Issues](#jvmjdk-version-issues) + +--- + +## Compose Version Conflicts + +### Error 1: Compose Runtime Mismatch + +``` +java.lang.IllegalStateException: Version mismatch: Compose runtime is 1.10.0 but compiler is 1.9.0 +``` + +**Cause:** Compose Compiler plugin version doesn't match Compose Runtime + +**Solution:** +```kotlin +// In gradle/libs.versions.toml +composeMultiplatform = "1.9.3" // Must align with Kotlin version +kotlin = "2.3.0" + +// Check compatibility matrix: +// https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html +``` + +**Verification:** +```bash +./gradlew :commons:dependencies | grep compose +``` + +### Error 2: AndroidX Compose BOM Conflict + +``` +Duplicate class androidx.compose.ui.platform.AndroidCompositionLocalMap found in modules... +``` + +**Cause:** Both Compose Multiplatform and AndroidX Compose BOM providing same classes + +**Solution:** +```kotlin +// In commons/build.gradle.kts (KMP module) +// Use Compose Multiplatform, NOT AndroidX BOM +dependencies { + implementation(compose.ui) // ✅ Compose Multiplatform + implementation(compose.material3) + + // Don't use in KMP modules: + // implementation(libs.androidx.compose.bom) // ❌ Android-only +} + +// In amethyst/build.gradle.kts (Android-only module) +// Can use AndroidX BOM +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + implementation(libs.androidx.ui) +} +``` + +### Error 3: Material3 WindowSizeClass Not Found + +``` +Unresolved reference: WindowSizeClass +``` + +**Cause:** Using Android's WindowSizeClass in shared KMP code + +**Solution:** +```kotlin +// Don't use in commonMain or jvmAndroid: +// import androidx.compose.material3.windowsizeclass.WindowSizeClass // ❌ + +// Use in androidMain only, or create expect/actual: +// commonMain +expect class WindowSizeClassAdapter + +// androidMain +actual typealias WindowSizeClassAdapter = androidx.compose.material3.windowsizeclass.WindowSizeClass + +// jvmMain (desktop) +actual class WindowSizeClassAdapter { /* Custom impl */ } +``` + +--- + +## secp256k1 JNI Errors + +### Error 1: JNI Library Not Found (Desktop) + +``` +java.lang.UnsatisfiedLinkError: no secp256k1jni in java.library.path +``` + +**Cause:** Desktop using wrong secp256k1 variant (Android JNI instead of JVM JNI) + +**Solution:** +```kotlin +// In quartz/build.gradle.kts +sourceSets { + jvmMain { + dependencies { + // ✅ Correct - JVM variant + implementation(libs.secp256k1.kmp.jni.jvm) + + // ❌ Wrong - Android variant + // implementation(libs.secp256k1.kmp.jni.android) + } + } +} +``` + +**Verification:** +```bash +./gradlew :quartz:dependencies --configuration jvmRuntimeClasspath | grep secp256k1 +# Should show: secp256k1-kmp-jni-jvm, NOT jni-android +``` + +### Error 2: Version Mismatch Between Variants + +``` +java.lang.NoSuchMethodError: fr.acinq.secp256k1.Secp256k1.sign +``` + +**Cause:** Common, Android, and JVM variants have different versions + +**Solution:** +```toml +# In gradle/libs.versions.toml +# All three MUST use same version +secp256k1KmpJniAndroid = "0.22.0" + +[libraries] +secp256k1-kmp-common = { ..., version.ref = "secp256k1KmpJniAndroid" } +secp256k1-kmp-jni-android = { ..., version.ref = "secp256k1KmpJniAndroid" } +secp256k1-kmp-jni-jvm = { ..., version.ref = "secp256k1KmpJniAndroid" } +``` + +### Error 3: Android JNI Not Loaded + +``` +java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader couldn't find "libsecp256k1jni.so" +``` + +**Cause:** Proguard stripping JNI classes + +**Solution:** +```proguard +# In quartz/proguard-rules.pro +-keep class fr.acinq.secp256k1.** { *; } +``` + +--- + +## Source Set Dependency Issues + +### Error 1: jvmAndroid Defined After androidMain + +``` +Could not get unknown property 'jvmAndroid' for source set container +``` + +**Cause:** Source sets must be defined in dependency order + +**Solution:** +```kotlin +// ✅ Correct order +sourceSets { + commonMain { } + + // Define jvmAndroid BEFORE androidMain and jvmMain + val jvmAndroid = create("jvmAndroid") { + dependsOn(commonMain.get()) + } + + androidMain { + dependsOn(jvmAndroid) // Now jvmAndroid exists + } + + jvmMain { + dependsOn(jvmAndroid) + } +} +``` + +### Error 2: Dependency in Wrong Source Set + +``` +Unresolved reference: ObjectMapper (Jackson) +``` + +**Cause:** JVM-only library in commonMain + +**Solution:** +```kotlin +sourceSets { + commonMain { + // ❌ Jackson is JVM-only, can't use here + // implementation(libs.jackson.module.kotlin) + } + + val jvmAndroid = create("jvmAndroid") { + dependsOn(commonMain.get()) + // ✅ Jackson in jvmAndroid (shared JVM code) + api(libs.jackson.module.kotlin) + } +} +``` + +### Error 3: Platform-Specific Code in Shared Source Set + +``` +java.lang.NoClassDefFoundError: android.content.Context +``` + +**Cause:** Android-specific API in jvmAndroid or commonMain + +**Solution:** +```kotlin +// Use expect/actual pattern + +// commonMain +expect class PlatformContext + +// androidMain +actual typealias PlatformContext = android.content.Context + +// jvmMain +actual class PlatformContext { + // Custom desktop implementation +} +``` + +--- + +## Proguard/R8 Issues + +### Error 1: Native Library Classes Stripped + +``` +java.lang.NoClassDefFoundError: com.goterl.lazysodium.Sodium +``` + +**Cause:** R8/Proguard removing JNA/LibSodium classes + +**Solution:** +```proguard +# In quartz/proguard-rules.pro +-keep class com.goterl.lazysodium.** { *; } +-keep class com.sun.jna.** { *; } +-keep class fr.acinq.secp256k1.** { *; } +``` + +### Error 2: Reflection-Based Libraries Broken + +``` +com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of ... +``` + +**Cause:** Jackson uses reflection, R8 strips class metadata + +**Solution:** +```proguard +# Preserve reflection metadata +-keepattributes *Annotation* +-keepattributes Signature +-keepattributes InnerClasses + +# Keep all Quartz event classes +-keep class com.vitorpamplona.quartz.** { *; } +``` + +### Error 3: Enum Values Missing + +``` +java.lang.IllegalArgumentException: No enum constant ... +``` + +**Cause:** R8 obfuscating enum names + +**Solution:** +```proguard +# Keep all enums +-keep enum ** { *; } +-keepnames class ** { *; } +``` + +--- + +## Desktop Packaging Errors + +### Error 1: Icon Not Found + +``` +FAILURE: Build failed with an exception. +* What went wrong: Cannot find icon file: src/jvmMain/resources/icon.icns +``` + +**Cause:** Icon file missing or wrong path + +**Solution:** +```kotlin +// In desktopApp/build.gradle.kts +nativeDistributions { + macOS { + // Ensure file exists at this path + iconFile.set(project.file("src/jvmMain/resources/icon.icns")) + } + + // Check file exists: + // ls -la desktopApp/src/jvmMain/resources/ +} +``` + +**Icon Requirements:** +- macOS: `.icns` (512x512, 256x256, 128x128, 32x32) +- Windows: `.ico` (256x256, 128x128, 64x64, 32x32, 16x16) +- Linux: `.png` (512x512 recommended) + +### Error 2: Main Class Not Found + +``` +Error: Could not find or load main class com.vitorpamplona.amethyst.desktop.MainKt +``` + +**Cause:** Wrong mainClass path or Main.kt doesn't have main() + +**Solution:** +```kotlin +// In desktopApp/build.gradle.kts +compose.desktop { + application { + mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" + // ^^^^ + // Kotlin compiler adds "Kt" suffix + } +} + +// In src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +fun main() = application { + // ... +} +``` + +### Error 3: Native Library Missing in Package + +``` +java.lang.UnsatisfiedLinkError: no secp256k1jni in java.library.path +``` + +**Cause:** Native libraries not bundled in distribution + +**Solution:** +```kotlin +// Native libs are automatically included via dependencies +// Verify secp256k1-kmp-jni-jvm is in dependencies: +dependencies { + implementation(libs.secp256k1.kmp.jni.jvm) // ✅ Includes native libs +} + +// Test packaged app: +./gradlew :desktopApp:createDistributable +# Run from: desktopApp/build/compose/binaries/main/app/ +``` + +--- + +## Kotlin Compilation Errors + +### Error 1: Expect/Actual Mismatch + +``` +'actual' declaration has no corresponding expected declaration +``` + +**Cause:** Signature mismatch or missing expect + +**Solution:** +```kotlin +// commonMain - expect declaration +expect class CryptoProvider { + fun sign(message: ByteArray, privateKey: ByteArray): ByteArray +} + +// androidMain & jvmMain - actual must match EXACTLY +actual class CryptoProvider { + actual fun sign(message: ByteArray, privateKey: ByteArray): ByteArray { + // Implementation + } +} + +// Common mistakes: +// - Different parameter names ❌ +// - Different return types ❌ +// - Missing 'actual' modifier ❌ +``` + +### Error 2: Target JVM Version Mismatch + +``` +Compilation failed: module was compiled with an incompatible version of Kotlin +``` + +**Cause:** Different JVM targets across modules + +**Solution:** +```kotlin +// Ensure ALL modules use same JVM target + +// In quartz/build.gradle.kts +kotlin { + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) // ✅ Java 21 + } + } +} + +// In android {} block +compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} +``` + +### Error 3: Compose Compiler Plugin Missing + +``` +This declaration needs opt-in. Please use @OptIn(ComposeApi::class) or @Composable +``` + +**Cause:** Compose compiler plugin not applied + +**Solution:** +```kotlin +// In build.gradle.kts +plugins { + alias(libs.plugins.jetbrainsComposeCompiler) // ✅ Add this + alias(libs.plugins.composeMultiplatform) +} +``` + +--- + +## Dependency Resolution Failures + +### Error 1: Repository Not Found + +``` +Could not find com.github.vitorpamplona.compose-richtext:richtext-ui:f92ef49c9d +``` + +**Cause:** Jitpack or custom Maven repository not configured + +**Solution:** +```kotlin +// In settings.gradle +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { url = "https://jitpack.io" } // ✅ Add Jitpack + } +} +``` + +### Error 2: Gradle Version Too Old + +``` +Version catalogs are not supported in this version of Gradle +``` + +**Cause:** Gradle < 7.0 + +**Solution:** +```properties +# In gradle/wrapper/gradle-wrapper.properties +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +``` + +Then: `./gradlew wrapper --gradle-version=8.9` + +### Error 3: Dependency Variant Not Found + +``` +No matching variant of fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.22.0 was found +``` + +**Cause:** Wrong dependency configuration for target + +**Solution:** +```kotlin +// In androidMain (Android library module) +dependencies { + // For AAR packaging + implementation("net.java.dev.jna:jna:5.18.1@aar") // ✅ Specify @aar + + // secp256k1 works without @aar (auto-detects) + api(libs.secp256k1.kmp.jni.android) +} +``` + +--- + +## JVM/JDK Version Issues + +### Error 1: Unsupported Class File Version + +``` +Unsupported class file major version 65 +``` + +**Cause:** Compiled with Java 21, running with older Java + +**Solution:** +```bash +# Check Java version +java -version # Should show 21 + +# Set JAVA_HOME if needed +export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home + +# Or in gradle.properties +org.gradle.java.home=/path/to/jdk-21 +``` + +### Error 2: JVM Toolchain Not Found + +``` +No matching toolchain found for requested JvmVersion +``` + +**Cause:** Java 21 not installed or not detected + +**Solution:** +```bash +# macOS (Homebrew) +brew install openjdk@21 + +# Ubuntu +sudo apt install openjdk-21-jdk + +# Set JAVA_HOME +export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS +export JAVA_HOME=/usr/lib/jvm/java-21-openjdk # Linux + +# Verify +./gradlew -version +``` + +### Error 3: Gradle Daemon Using Wrong Java + +``` +Daemon will be stopped at the end of the build because JVM version has changed +``` + +**Cause:** Daemon started with different Java version + +**Solution:** +```bash +# Stop all daemons +./gradlew --stop + +# Start with correct JAVA_HOME +export JAVA_HOME=/path/to/jdk-21 +./gradlew build + +# Or set in gradle.properties permanently +org.gradle.java.home=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home +``` + +--- + +## General Troubleshooting Steps + +### Step 1: Clean Build +```bash +./gradlew clean +./gradlew --stop # Stop daemon +./gradlew build +``` + +### Step 2: Check Dependencies +```bash +./gradlew :moduleName:dependencies +./gradlew dependencyInsight --dependency libraryName +``` + +### Step 3: Enable Debug Logging +```bash +./gradlew build --info # Info logging +./gradlew build --debug # Debug logging (verbose) +./gradlew build --stacktrace +``` + +### Step 4: Invalidate Caches +```bash +# Clear Gradle cache +rm -rf ~/.gradle/caches/ + +# Clear build outputs +./gradlew clean + +# Clear Gradle wrapper cache +rm -rf ~/.gradle/wrapper/ +``` + +### Step 5: Build Scan +```bash +./gradlew build --scan +# Opens interactive diagnostics in browser +``` + +## Quick Reference: Error Keywords → Solution + +| Error Keyword | Likely Cause | Quick Fix | +|---------------|--------------|-----------| +| `UnsatisfiedLinkError` | Wrong JNI variant | Check secp256k1/JNA variants by platform | +| `IllegalStateException` (Compose) | Version mismatch | Align Compose Multiplatform + Kotlin versions | +| `NoClassDefFoundError` | Proguard stripping | Add `-keep` rule for class | +| `Unresolved reference` | Wrong source set | Move to appropriate source set (jvmAndroid) | +| `Duplicate class` | BOM conflict | Remove AndroidX BOM from KMP modules | +| `Version mismatch` | Plugin/runtime version mismatch | Update libs.versions.toml | +| `No matching variant` | Repository or packaging issue | Add repository or @aar suffix | +| `Could not find` (dependency) | Missing repository | Add maven/jitpack to repositories | +| `Unsupported class file` | Java version mismatch | Update JAVA_HOME to Java 21 | + +--- + +## Getting Help + +1. **Check Build Scan**: `./gradlew build --scan` for detailed diagnostics +2. **Gradle Forums**: https://discuss.gradle.org/ +3. **Kotlin Slack**: #multiplatform channel +4. **Stack Overflow**: Tags `gradle`, `kotlin-multiplatform`, `compose-multiplatform` diff --git a/.claude/skills/gradle-expert/references/dependency-graph.md b/.claude/skills/gradle-expert/references/dependency-graph.md new file mode 100644 index 0000000000..3f38203989 --- /dev/null +++ b/.claude/skills/gradle-expert/references/dependency-graph.md @@ -0,0 +1,266 @@ +# Module Dependency Graph + +## Visual Hierarchy + +``` +┌─────────────────────────────────────────────────────────┐ +│ Root Project │ +│ (Amethyst) │ +└─────────────────────────────────────────────────────────┘ + │ + ┌────────────────┼────────────────┬────────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ +│ :amethyst │ │ :desktopApp │ │ :benchmark │ │:ammolite │ +│ (Android) │ │ (JVM) │ │ (Android) │ │ (Support)│ +└─────────────┘ └─────────────┘ └─────────────┘ └──────────┘ + │ │ │ + │ │ │ + └────────────────┼────────────────┘ + │ + ▼ + ┌─────────────┐ + │ :commons │ + │ (KMP UI) │ + │ │ + │ jvmAndroid │ + │ / \ │ + │ jvm android│ + └─────────────┘ + │ + │ + ▼ + ┌─────────────┐ + │ :quartz │ + │(KMP Library)│ + │ │ + │ commonMain │ + │ │ │ + │ jvmAndroid │ + │ / | \ │ + │jvm and ios │ + └─────────────┘ +``` + +## Module Details + +### :quartz (KMP Nostr Library) +**Type:** Kotlin Multiplatform Library +**Targets:** JVM, Android, iOS (iosX64, iosArm64, iosSimulatorArm64) +**Dependencies:** +- External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable +- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain + +**Role:** Core Nostr protocol implementation, shared across all platforms + +### :commons (Shared UI Components) +**Type:** Kotlin Multiplatform Library +**Targets:** JVM, Android +**Dependencies:** +- Module: `:quartz` +- External: Compose Multiplatform, Material3, kotlinx.collections.immutable +- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain} + +**Role:** Shared Compose UI components for Desktop and Android + +### :desktopApp (Desktop Application) +**Type:** JVM Application +**Targets:** JVM (Desktop) +**Dependencies:** +- Modules: `:commons`, `:quartz` +- External: Compose Desktop, kotlinx.coroutines.swing + +**Role:** Desktop-specific navigation, layouts, and entry point + +### :amethyst (Android Application) +**Type:** Android Application +**Targets:** Android +**Dependencies:** +- Modules: `:commons`, `:quartz`, `:ammolite` +- External: Android SDK, AndroidX, Firebase, Tor + +**Role:** Android-specific navigation, layouts, and entry point + +### :benchmark (Android Benchmark) +**Type:** Android Library +**Targets:** Android +**Dependencies:** +- Modules: `:commons`, `:quartz` +- External: AndroidX Benchmark + +**Role:** Performance benchmarking for Android builds + +### :ammolite (Support Module) +**Type:** Android Library +**Targets:** Android +**Dependencies:** Android-specific utilities + +**Role:** Android support utilities for amethyst + +## Dependency Flow Patterns + +### Desktop Build Chain +``` +:desktopApp → :commons (jvmMain) → :quartz (jvmMain) + ↓ + jvmAndroid + ↓ + commonMain +``` + +### Android Build Chain +``` +:amethyst → :commons (androidMain) → :quartz (androidMain) + ↓ ↓ +:ammolite jvmAndroid + ↓ + commonMain +``` + +## Source Set Dependencies + +### :quartz Source Sets +``` +commonMain (base) + ├─ jvmAndroid (shared JVM code) + │ ├─ androidMain (Android platform) + │ └─ jvmMain (Desktop platform) + └─ iosMain (iOS platform) + ├─ iosX64Main + ├─ iosArm64Main + └─ iosSimulatorArm64Main +``` + +**Key Dependencies per Source Set:** +- **commonMain**: secp256k1-kmp, kotlinx.coroutines, collection, immutable collections +- **jvmAndroid**: jackson, okhttp, url-detector, rfc3986 +- **androidMain**: secp256k1-kmp-jni-android, lazysodium-android, jna (aar) +- **jvmMain**: secp256k1-kmp-jni-jvm, lazysodium-java, jna (jar) + +### :commons Source Sets +``` +commonMain (base UI) + └─ jvmAndroid (shared JVM UI) + ├─ androidMain (Android UI utilities) + └─ jvmMain (Desktop UI utilities) +``` + +**Key Dependencies per Source Set:** +- **commonMain**: Compose Multiplatform, Material3, :quartz +- **jvmAndroid**: url-detector +- **androidMain**: AndroidX Compose tooling +- **jvmMain**: Compose Desktop + +## Critical Dependency Patterns + +### 1. secp256k1 Variants +```kotlin +// commonMain - API only +api(libs.secp256k1.kmp.common) + +// androidMain - JNI Android +api(libs.secp256k1.kmp.jni.android) + +// jvmMain - JNI JVM +implementation(libs.secp256k1.kmp.jni.jvm) +``` +**Why:** Different JNI bindings for Android vs Desktop JVM + +### 2. JNA Variants (for LibSodium) +```kotlin +// androidMain +implementation("com.goterl:lazysodium-android:5.2.0@aar") +implementation("net.java.dev.jna:jna:5.18.1@aar") + +// jvmMain +implementation(libs.lazysodium.java) +implementation(libs.jna) // JAR variant +``` +**Why:** Android needs AAR packaging, JVM needs JAR + +### 3. Compose Alignment +```kotlin +// commons/build.gradle.kts +implementation(compose.ui) // Compose Multiplatform BOM +implementation(compose.material3) + +// Version catalog alignment +composeMultiplatform = "1.9.3" +composeBom = "2025.12.01" // AndroidX Compose +``` +**Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align + +## Dependency Configuration Types + +### API vs Implementation + +**Use `api` when:** +- Dependency types appear in module's public API +- Used in expect/actual declarations visible to consumers +- Example: `secp256k1-kmp-common` in quartz (public types) + +**Use `implementation` when:** +- Internal implementation detail +- Not exposed to module consumers +- Example: `okhttp` in quartz (internal network client) + +### Example from quartz +```kotlin +// Public API - exposed to consumers +api(libs.secp256k1.kmp.common) +api(libs.jackson.module.kotlin) // Event serialization public + +// Internal implementation +implementation(libs.okhttp) +implementation(libs.kotlinx.coroutines.core) +``` + +## Transitive Dependency Impact + +### When :desktopApp depends on :commons +- Gets `:quartz` transitively (via :commons) +- Gets `secp256k1-kmp-jvm` transitively (via :quartz jvmMain) +- Does NOT get Android-specific dependencies (scoped to androidMain) + +### When :amethyst depends on :commons +- Gets `:quartz` transitively (via :commons) +- Gets `secp256k1-kmp-jni-android` transitively (via :quartz androidMain) +- Does NOT get JVM/Desktop-specific dependencies (scoped to jvmMain) + +## Verifying Dependencies + +### Check Module Dependencies +```bash +./gradlew :desktopApp:dependencies +./gradlew :amethyst:dependencies +``` + +### Check Specific Library +```bash +./gradlew dependencyInsight --dependency secp256k1 +./gradlew dependencyInsight --dependency compose-ui +``` + +### Visualize with Build Scan +```bash +./gradlew :desktopApp:dependencies --scan +# Opens interactive dependency graph in browser +``` + +## Common Dependency Issues + +### Issue 1: Wrong secp256k1 Variant in Desktop +**Symptom:** `UnsatisfiedLinkError: no secp256k1jni in java.library.path` +**Cause:** Desktop using Android JNI variant +**Fix:** Ensure jvmMain uses `secp256k1-kmp-jni-jvm` + +### Issue 2: Compose Version Mismatch +**Symptom:** `IllegalStateException: Version mismatch` +**Cause:** Compose Multiplatform plugin vs runtime version mismatch +**Fix:** Align `composeMultiplatform` version in libs.versions.toml with Kotlin plugin + +### Issue 3: Duplicate JNA Classes +**Symptom:** `DuplicateClassException: com.sun.jna.Native` +**Cause:** Both JAR and AAR JNA variants in classpath +**Fix:** Use AAR (@aar) in androidMain, JAR in jvmMain (never in shared source sets) diff --git a/.claude/skills/gradle-expert/references/version-catalog-guide.md b/.claude/skills/gradle-expert/references/version-catalog-guide.md new file mode 100644 index 0000000000..0c50df3573 --- /dev/null +++ b/.claude/skills/gradle-expert/references/version-catalog-guide.md @@ -0,0 +1,422 @@ +# Version Catalog Guide + +## Overview + +AmethystMultiplatform uses Gradle's version catalog (`gradle/libs.versions.toml`) to centralize dependency management. This ensures version consistency across all modules and simplifies updates. + +## Structure + +### sections +```toml +[versions] # Version numbers (referenced by libraries and plugins) +[libraries] # Library dependencies +[plugins] # Gradle plugins +``` + +## Version References + +### Defining Versions +```toml +[versions] +kotlin = "2.3.0" +composeMultiplatform = "1.9.3" +okhttp = "5.3.2" +``` + +### Special Patterns + +#### Android SDK Versions +```toml +android-compileSdk = "36" +android-minSdk = "26" +android-targetSdk = "36" +``` +**Access in build.gradle.kts:** +```kotlin +compileSdk = libs.versions.android.compileSdk.get().toInt() +minSdk = libs.versions.android.minSdk.get().toInt() +``` + +#### Version Suffixes (Git Commits) +```toml +androidKotlinGeohash = "b481c6a64e" # Jitpack commit hash +markdown = "f92ef49c9d" +``` +**Why:** For GitHub dependencies via Jitpack that don't have semantic versions + +## Library Declarations + +### Basic Pattern +```toml +[libraries] +library-name = { group = "...", name = "...", version.ref = "..." } +``` + +### Examples + +#### Version Reference +```toml +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +``` + +#### Module Reference (for multi-artifact libs) +```toml +androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidxCamera" } +``` + +#### Without Group (shorthand) +```toml +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +``` +**Note:** Inherits version from BOM (compose-bom) + +### BOMs (Bill of Materials) + +#### AndroidX Compose BOM +```toml +[versions] +composeBom = "2025.12.01" + +[libraries] +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +``` + +**Usage in build.gradle.kts:** +```kotlin +val composeBom = platform(libs.androidx.compose.bom) +implementation(composeBom) +implementation(libs.androidx.ui) // Version from BOM +implementation(libs.androidx.material3) // Version from BOM +``` + +**Benefits:** +- All AndroidX Compose artifacts use compatible versions +- Update single BOM version, not individual libraries +- Prevents version conflicts + +### Platform-Specific Variants + +#### secp256k1 (KMP crypto library) +```toml +secp256k1KmpJniAndroid = "0.22.0" + +[libraries] +secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } +secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } +secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } +``` + +**Critical:** All three variants MUST share the same version + +#### JNA (for LibSodium) +```toml +jna = "5.18.1" + +[libraries] +jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } +``` + +**Usage in build.gradle.kts:** +```kotlin +// androidMain - AAR packaging +implementation("net.java.dev.jna:jna:5.18.1@aar") + +// jvmMain - JAR packaging +implementation(libs.jna) +``` + +**Why:** Android needs AAR, JVM needs JAR (different artifact types) + +## Plugin Declarations + +### Basic Pattern +```toml +[plugins] +plugin-id = { id = "...", version.ref = "..." } +``` + +### Examples + +#### Kotlin Plugins +```toml +[versions] +kotlin = "2.3.0" + +[plugins] +jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlinxSerializationPlugin' } +``` + +**Critical:** All Kotlin plugins MUST use the same Kotlin version + +#### Android Gradle Plugin +```toml +[versions] +agp = "8.13.2" + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +``` + +#### Compose Multiplatform +```toml +[versions] +composeMultiplatform = "1.9.3" + +[plugins] +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +``` + +### Plugin Application + +```kotlin +// In build.gradle.kts +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.jetbrainsComposeCompiler) +} +``` + +## Usage in Build Files + +### Accessing Versions +```kotlin +// Direct version access +val kotlinVersion = libs.versions.kotlin.get() +val minSdk = libs.versions.android.minSdk.get().toInt() +``` + +### Accessing Libraries +```kotlin +dependencies { + implementation(libs.kotlinx.coroutines.core) + api(libs.secp256k1.kmp.common) + implementation(libs.okhttp) +} +``` + +### Accessing Plugins +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) +} +``` + +## Version Catalog Benefits + +### 1. Centralized Version Management +Update once, applies everywhere: +```toml +# Change one line +kotlin = "2.3.0" → "2.4.0" + +# Affects all usages +- kotlinMultiplatform plugin +- jetbrainsKotlinAndroid plugin +- kotlin-stdlib +- All Kotlin-related dependencies +``` + +### 2. Type-Safe Accessors +```kotlin +// Compile-time checked +implementation(libs.okhttp) // ✅ IDE autocomplete + +// vs string-based (error-prone) +implementation("com.squareup.okhttp3:okhttp:5.3.2") // ❌ No autocomplete +``` + +### 3. Dependency Consistency +```kotlin +// All modules reference same catalog +:quartz → libs.okhttp +:commons → libs.okhttp +:desktopApp → libs.okhttp + +// Same version everywhere +``` + +### 4. Gradle Sync Improvements +- Faster IDE sync (pre-parsed catalog) +- Better dependency resolution +- Clearer error messages + +## Common Patterns + +### GitHub Dependencies (Jitpack) +```toml +[versions] +markdown = "f92ef49c9d" # Git commit hash + +[libraries] +markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" } +``` + +**Repository config** (in settings.gradle): +```kotlin +repositories { + maven { url = "https://jitpack.io" } +} +``` + +### Multi-Artifact Libraries +```toml +[versions] +media3 = "1.9.0" + +[libraries] +androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } +androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } +``` + +**Why:** All media3 artifacts share same version for compatibility + +### Test Dependencies +```toml +[libraries] +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} +``` + +## Version Update Strategy + +### Check for Updates +```bash +# Using Gradle Versions Plugin (if installed) +./gradlew dependencyUpdates + +# Manual check +# Browse to Maven Central for specific library +``` + +### Update Process +1. **Update version in catalog** + ```toml + okhttp = "5.3.2" → "5.4.0" + ``` + +2. **Test locally** + ```bash + ./gradlew clean build + ``` + +3. **Check for breaking changes** + - Review library changelog + - Run full test suite + +4. **Commit with clear message** + ``` + chore: update okhttp 5.3.2 → 5.4.0 + ``` + +### Critical Version Alignments + +#### Kotlin Ecosystem +```toml +kotlin = "2.3.0" +kotlinxCoroutinesCore = "1.10.2" +kotlinxSerialization = "1.9.0" +``` +**Rule:** Kotlin version must be compatible with kotlinx libraries + +#### Compose Ecosystem +```toml +composeMultiplatform = "1.9.3" +composeBom = "2025.12.01" +kotlin = "2.3.0" +``` +**Rule:** Compose Multiplatform → Kotlin version (see compatibility matrix) + +#### AGP & Gradle +```toml +agp = "8.13.2" +# Requires Gradle 8.9+ +``` +**Rule:** AGP version dictates minimum Gradle version + +## Troubleshooting + +### Issue 1: Unresolved Reference +**Error:** `Unresolved reference: libs` + +**Cause:** Gradle version < 7.0 (version catalogs not supported) + +**Fix:** Upgrade Gradle in `gradle/wrapper/gradle-wrapper.properties` + +### Issue 2: Library Not Found +**Error:** `Could not find com.example:library:1.0.0` + +**Cause:** Repository not configured or typo in catalog + +**Fix:** +1. Check repository in settings.gradle +2. Verify group/name/version in libs.versions.toml + +### Issue 3: Version Conflict +**Error:** `Conflict with dependency ... and ...` + +**Cause:** Different versions of same library via transitive dependencies + +**Fix:** +```kotlin +configurations.all { + resolutionStrategy { + force(libs.okhttp.get().toString()) + } +} +``` + +## Best Practices + +### 1. Naming Conventions +```toml +# Hyphen-separated, hierarchical +androidx-compose-ui +androidx-compose-material3 +kotlinx-coroutines-core + +# Platform suffixes +secp256k1-kmp-jni-android +secp256k1-kmp-jni-jvm +``` + +### 2. Group Related Dependencies +```toml +# Camera APIs together +androidx-camera-core +androidx-camera-camera2 +androidx-camera-view +``` + +### 3. Document Special Cases +```toml +# JNA requires @aar for Android (see build.gradle.kts) +jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } +``` + +### 4. Keep BOMs Updated +```toml +# Update BOM, individual libs follow +composeBom = "2025.12.01" # Latest stable +``` + +### 5. Test Version Updates +```bash +# Before committing +./gradlew :quartz:test +./gradlew :commons:test +./gradlew :desktopApp:run +``` diff --git a/.claude/skills/gradle-expert/scripts/analyze-build-time.sh b/.claude/skills/gradle-expert/scripts/analyze-build-time.sh new file mode 100644 index 0000000000..49dad60f4a --- /dev/null +++ b/.claude/skills/gradle-expert/scripts/analyze-build-time.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Analyze Gradle build performance and generate report + +set -e + +PROJECT_ROOT="${1:-.}" +cd "$PROJECT_ROOT" + +echo "🔍 Analyzing Gradle build performance..." +echo "========================================" +echo "" + +# Clean build for accurate timing +echo "Running clean build with --profile..." +./gradlew clean build --profile --scan + +# Find the latest profile report +PROFILE_REPORT=$(find build/reports/profile -name "*.html" -type f -printf '%T@ %p\n' | sort -n | tail -1 | cut -f2- -d" ") + +if [ -n "$PROFILE_REPORT" ]; then + echo "" + echo "✅ Profile report generated: $PROFILE_REPORT" + echo "" + echo "📊 Build Performance Summary:" + echo "----------------------------" + + # Extract key metrics if available + if command -v jq &> /dev/null && [ -f "build/reports/profile/profile.json" ]; then + jq -r '.buildTime, .taskExecutionTime' build/reports/profile/profile.json + else + echo "Open the HTML report for detailed analysis:" + echo "file://$PWD/$PROFILE_REPORT" + fi +else + echo "⚠️ Profile report not found" +fi + +echo "" +echo "💡 Build optimization tips:" +echo "- Enable Gradle daemon: org.gradle.daemon=true" +echo "- Parallel execution: org.gradle.parallel=true" +echo "- Configuration cache: org.gradle.configuration-cache=true" +echo "- Build cache: org.gradle.caching=true" +echo "" +echo "Add these to gradle.properties for faster builds" diff --git a/.claude/skills/gradle-expert/scripts/fix-dependency-conflicts.sh b/.claude/skills/gradle-expert/scripts/fix-dependency-conflicts.sh new file mode 100644 index 0000000000..17951ace44 --- /dev/null +++ b/.claude/skills/gradle-expert/scripts/fix-dependency-conflicts.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Diagnose and suggest fixes for common dependency conflicts + +set -e + +PROJECT_ROOT="${1:-.}" +cd "$PROJECT_ROOT" + +echo "🔍 Analyzing dependency conflicts..." +echo "====================================" +echo "" + +# Run dependency report +echo "Generating dependency insight report..." +./gradlew dependencies --configuration runtimeClasspath > /tmp/gradle-dependencies.txt 2>&1 || true + +# Check for common conflict patterns +echo "" +echo "🔎 Checking for common issues:" +echo "------------------------------" + +# Check 1: Compose version conflicts +if grep -q "compose" /tmp/gradle-dependencies.txt; then + echo "✓ Compose dependencies found" + echo " Tip: Ensure Compose Multiplatform and AndroidX Compose versions align" + echo " Current project uses:" + echo " - Compose Multiplatform BOM" + echo " - AndroidX Compose BOM" +fi + +# Check 2: secp256k1 variants +if grep -q "secp256k1" /tmp/gradle-dependencies.txt; then + echo "✓ secp256k1 dependencies found" + echo " Ensure correct variant:" + echo " - Android: secp256k1-kmp-jni-android" + echo " - JVM/Desktop: secp256k1-kmp-jni-jvm" + echo " - Common: secp256k1-kmp (transitive)" +fi + +# Check 3: Kotlin version alignment +KOTLIN_VERSION=$(grep "kotlin =" gradle/libs.versions.toml | cut -d'"' -f2) +echo "✓ Kotlin version: $KOTLIN_VERSION" +echo " All Kotlin plugins should use the same version" + +# Check 4: Multiple versions of same library +echo "" +echo "🔍 Checking for version conflicts..." +./gradlew dependencyInsight --configuration runtimeClasspath --dependency okhttp || true + +echo "" +echo "💡 Common fixes:" +echo "---------------" +echo "1. Compose conflicts:" +echo " - Align compose-multiplatform plugin version with runtime" +echo " - Use BOM for AndroidX Compose to enforce consistency" +echo "" +echo "2. secp256k1 conflicts:" +echo " - Use 'api' instead of 'implementation' in source sets" +echo " - Ensure androidMain uses jni-android, jvmMain uses jni-jvm" +echo "" +echo "3. Kotlin version conflicts:" +echo " - Update all kotlin plugins to same version in libs.versions.toml" +echo " - Check for transitive Kotlin dependencies" +echo "" +echo "Run './gradlew dependencyInsight --dependency ' for specific conflicts" diff --git a/.claude/skills/nostr-expert/SKILL.md b/.claude/skills/nostr-expert/SKILL.md new file mode 100644 index 0000000000..692f2fe767 --- /dev/null +++ b/.claude/skills/nostr-expert/SKILL.md @@ -0,0 +1,551 @@ +--- +name: nostr-expert +description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details. +--- + +# Nostr Protocol Expert (Quartz Implementation) + +Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP Nostr library. + +## When to Use This Skill + +- Implementing Nostr event types (TextNote, Reaction, Zap, etc.) +- Creating/parsing events with TagArrayBuilder DSL +- Working with event kinds and tags +- Finding NIP implementations in quartz/ codebase +- Nostr cryptography (secp256k1 signing, NIP-44 encryption) +- Bech32 encoding/decoding (npub, nsec, note formats) +- Event validation and verification + +**For NIP specifications** → Use `nostr-protocol` agent +**For Quartz implementation** → Use this skill + +## Quartz Architecture + +Quartz organizes code by NIP number: + +``` +quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/ +├── nip01Core/ # Core protocol (Event, Kind, Tags) +├── nip04Dm/ # Legacy DMs (deprecated) +├── nip10Notes/ # Text notes with threading +├── nip17Dm/ # Private DMs (gift wrap) +├── nip19Bech32/ # Bech32 encoding +├── nip44Encryption/ # Modern encryption (ChaCha20) +├── nip57Zaps/ # Lightning zaps +├── ... (57 NIPs total) +└── experimental/ # Draft NIPs +``` + +**Pattern**: `nip##/` directories contain event classes, tags, and utilities for that NIP. + +**Find implementations**: Use `scripts/nip-lookup.sh ` or see `references/nip-catalog.md`. + +## Event Anatomy + +### Core Structure + +```kotlin +@Immutable +open class Event( + val id: HexKey, // SHA-256 hash of serialized event + val pubKey: HexKey, // Author's public key (32 bytes hex) + val createdAt: Long, // Unix timestamp + val kind: Kind, // Event kind (Int typealias) + val tags: TagArray, // Array of tag arrays + val content: String, // Event content + val sig: HexKey, // Schnorr signature (64 bytes hex) +) : IEvent +``` + +**Key insight**: `Event` is the base class. Specific event types (TextNoteEvent, ReactionEvent) extend it and add parsing/helper methods. + +### Kind Classification + +```kotlin +typealias Kind = Int + +fun Kind.isEphemeral() = this in 20000..29999 // Not stored +fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999 +fun Kind.isAddressable() = this in 30000..39999 // Replaceable + has d-tag +fun Kind.isRegular() = this in 1000..9999 // Stored, not replaced +``` + +**Pattern**: Kind determines event lifecycle and replaceability. + +## Creating Events + +### EventTemplate Pattern + +```kotlin +fun eventTemplate( + kind: Kind, + content: String, + tags: TagArray = emptyArray() +): EventTemplate +``` + +**Usage**: +```kotlin +val template = eventTemplate( + kind = 1, // Text note + content = "Hello Nostr!", + tags = tagArray { + add(arrayOf("subject", "Greeting")) + } +) + +// Sign with a signer +val signedEvent = signer.sign(template) +``` + +**Why templates?** Separates event data from signing. Templates can be signed by different signers (local keys, remote signers, hardware wallets). + +### TagArrayBuilder DSL + +```kotlin +fun tagArray( + initializer: TagArrayBuilder.() -> Unit +): TagArray +``` + +**Methods**: +- `add(tag)` - Append tag +- `addFirst(tag)` - Prepend tag (for ordering) +- `addUnique(tag)` - Replace all tags with this name +- `remove(tagName)` - Remove by name +- `addAll(tags)` - Bulk add + +**Example**: +```kotlin +val tags = tagArray { + add(arrayOf("e", replyToEventId, "", "reply")) + add(arrayOf("p", authorPubkey)) + addUnique(arrayOf("subject", "Re: Hello")) + add(arrayOf("content-warning", "spoilers")) +} +``` + +**Pattern**: Fluent DSL for building tag arrays with validation and deduplication. + +## Common Event Types + +### TextNoteEvent (kind 1) + +```kotlin +class TextNoteEvent : BaseThreadedEvent +``` + +**Creating**: +```kotlin +val note = eventTemplate( + kind = 1, + content = "Hello world!", + tags = tagArray { + add(arrayOf("subject", "First post")) + } +) +``` + +**Parsing**: +```kotlin +val event: TextNoteEvent = ... +val subject = event.subject() // Extension from nip14Subject +val mentions = event.mentions() // List of p-tags +val quotedEvents = event.quotes() // List of q-tags +``` + +### ReactionEvent (kind 7) + +```kotlin +fun createReaction( + targetEvent: Event, + emoji: String = "+" +): EventTemplate { + return eventTemplate( + kind = 7, + content = emoji, + tags = tagArray { + add(arrayOf("e", targetEvent.id)) + add(arrayOf("p", targetEvent.pubKey)) + } + ) +} +``` + +### MetadataEvent (kind 0) + +```kotlin +data class UserMetadata( + val name: String?, + val displayName: String?, + val picture: String?, + val banner: String?, + val about: String?, + // ... more fields +) + +fun createMetadata(metadata: UserMetadata): EventTemplate { + return eventTemplate( + kind = 0, + content = metadata.toJson() // Serialize to JSON + ) +} +``` + +### Addressable Events (kinds 30000-40000) + +```kotlin +fun createArticle( + slug: String, + title: String, + content: String +): EventTemplate { + return eventTemplate( + kind = 30023, + content = content, + tags = tagArray { + addUnique(arrayOf("d", slug)) // Unique identifier + add(arrayOf("title", title)) + add(arrayOf("published_at", "${TimeUtils.now()}")) + } + ) +} +``` + +**Key**: `d-tag` makes it addressable. Events with same kind + pubkey + d-tag replace each other. + +## Tag Patterns + +Tags are `Array` with pattern `[name, value, ...optionalParams]`. + +### Core Tags + +**e-tag** (event reference): +```kotlin +add(arrayOf("e", eventId, relayHint, marker)) +// marker: "reply", "root", "mention" +``` + +**p-tag** (pubkey reference): +```kotlin +add(arrayOf("p", pubkey, relayHint)) +``` + +**a-tag** (addressable event): +```kotlin +add(arrayOf("a", "$kind:$pubkey:$dtag", relayHint)) +``` + +**d-tag** (identifier for addressable events): +```kotlin +addUnique(arrayOf("d", "unique-slug")) +``` + +### Tag Extensions + +```kotlin +// Find tags +event.tags.tagValue("subject") // First subject tag value +event.tags.allTags("p") // All p-tags +event.tags.tagValues("e") // All e-tag values + +// Parse structured tags +event.tags.mapNotNull(ETag::parse) // Parse as ETag objects +``` + +For comprehensive tag patterns, see `references/tag-patterns.md`. + +## Threading (NIP-10) + +```kotlin +fun createReply( + original: TextNoteEvent, + content: String +): EventTemplate { + return eventTemplate( + kind = 1, + content = content, + tags = tagArray { + // Reply marker + add(arrayOf("e", original.id, "", "reply")) + + // Root marker (original's root, or original itself) + original.rootEvent()?.let { + add(arrayOf("e", it.id, "", "root")) + } ?: add(arrayOf("e", original.id, "", "root")) + + // Tag author + add(arrayOf("p", original.pubKey)) + + // Tag all mentioned users + original.mentions().forEach { + add(arrayOf("p", it)) + } + } + ) +} +``` + +**Pattern**: `reply` and `root` markers establish thread hierarchy. + +## Cryptography + +### Signing (secp256k1) + +```kotlin +interface ISigner { + suspend fun sign(template: EventTemplate): Event +} + +// Local key signing +class LocalSigner(private val privateKey: ByteArray) : ISigner { + override suspend fun sign(template: EventTemplate): Event { + val id = template.generateId() + val sig = Secp256k1.sign(id, privateKey) + return Event(id, pubKey, createdAt, kind, tags, content, sig) + } +} +``` + +**Pattern**: Signers abstract key management. Can be local, remote (NIP-46), or hardware. + +### Encryption (NIP-44) + +```kotlin +// Modern encryption (ChaCha20-Poly1305) +object Nip44v2 { + fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String + fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String +} + +// Usage +val encrypted = Nip44v2.encrypt( + plaintext = "Secret message", + privateKey = myPrivateKey, + pubKey = recipientPubKey +) + +val decrypted = Nip44v2.decrypt( + ciphertext = encrypted, + privateKey = myPrivateKey, + pubKey = senderPubKey +) +``` + +**Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD. + +### NIP-04 (Deprecated) + +```kotlin +// Legacy encryption (NIP-04, deprecated for NIP-44) +object Nip04 { + fun encrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String + fun decrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String +} +``` + +**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues. + +## Bech32 Encoding (NIP-19) + +```kotlin +object Nip19 { + // Encode + fun npubEncode(pubkey: HexKey): String // npub1... + fun nsecEncode(privateKey: ByteArray): String // nsec1... + fun noteEncode(eventId: HexKey): String // note1... + fun neventEncode(eventId: HexKey, relays: List = emptyList()): String + fun nprofileEncode(pubkey: HexKey, relays: List = emptyList()): String + fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List = emptyList()): String + + // Decode + fun decode(bech32: String): Nip19Result +} + +sealed class Nip19Result { + data class NPub(val hex: HexKey) : Nip19Result() + data class NSec(val hex: HexKey) : Nip19Result() + data class Note(val hex: HexKey) : Nip19Result() + data class NEvent(val hex: HexKey, val relays: List) : Nip19Result() + data class NProfile(val hex: HexKey, val relays: List) : Nip19Result() + data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List) : Nip19Result() +} +``` + +**Usage**: +```kotlin +// Encode +val npub = Nip19.npubEncode(pubkeyHex) +// Output: "npub1..." + +// Decode +when (val result = Nip19.decode(npub)) { + is Nip19Result.NPub -> println("Pubkey: ${result.hex}") + is Nip19Result.NEvent -> println("Event: ${result.hex}, relays: ${result.relays}") + else -> println("Other type") +} +``` + +## Event Validation + +```kotlin +fun Event.verify(): Boolean { + // 1. Verify ID matches content hash + val computedId = generateId() + if (id != computedId) return false + + // 2. Verify signature + return Secp256k1.verify(id, sig, pubKey) +} + +fun Event.generateId(): HexKey { + val serialized = serializeForId() // JSON array format + return sha256(serialized) +} +``` + +**Pattern**: Always verify events from untrusted sources (relays). + +## Common Workflows + +### Publishing an Event + +```kotlin +suspend fun publishNote(content: String, signer: ISigner, relays: List) { + // 1. Create template + val template = eventTemplate(kind = 1, content = content) + + // 2. Sign + val event = signer.sign(template) + + // 3. Verify (optional but recommended) + require(event.verify()) { "Signature verification failed" } + + // 4. Publish to relays + relays.forEach { relay -> + relayClient.send(relay, event) + } +} +``` + +### Querying Events + +```kotlin +// Subscription filter +data class Filter( + val ids: List? = null, + val authors: List? = null, + val kinds: List? = null, + val since: Long? = null, + val until: Long? = null, + val limit: Int? = null, + val tags: Map>? = null // e.g., {"#e": [eventId], "#p": [pubkey]} +) + +// Usage +val filter = Filter( + authors = listOf(userPubkey), + kinds = listOf(1), // Text notes only + limit = 50 +) + +relayClient.subscribe(relay, filter) { event -> + // Handle incoming events +} +``` + +### Creating a Zap (NIP-57) + +```kotlin +fun createZapRequest( + targetEvent: Event, + amountSats: Long, + comment: String = "" +): EventTemplate { + return eventTemplate( + kind = 9734, // Zap request + content = comment, + tags = tagArray { + add(arrayOf("e", targetEvent.id)) + add(arrayOf("p", targetEvent.pubKey)) + add(arrayOf("amount", "${amountSats * 1000}")) // millisats + add(arrayOf("relays", "wss://relay1.com", "wss://relay2.com")) + } + ) +} +``` + +### Gift-Wrapped DMs (NIP-17) + +```kotlin +fun createGiftWrappedDM( + recipientPubkey: HexKey, + message: String, + signer: ISigner +): Event { + // 1. Create sealed gossip (kind 14) + val sealedGossip = createSealedGossip(message, recipientPubkey, signer) + + // 2. Wrap in gift wrap (kind 1059) + return createGiftWrap(sealedGossip, recipientPubkey, signer) +} +``` + +**Pattern**: Double encryption + random ephemeral keys for metadata protection. + +## Finding NIPs + +Use the bundled script: + +```bash +# Find by NIP number +scripts/nip-lookup.sh 44 + +# Search by term +scripts/nip-lookup.sh encryption +scripts/nip-lookup.sh "gift wrap" +``` + +Or see `references/nip-catalog.md` for complete catalog. + +## Bundled Resources + +- **references/nip-catalog.md** - All 57 NIPs with package locations and key files +- **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types +- **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns +- **scripts/nip-lookup.sh** - Find NIP implementations by number or search term + +## Quick Reference + +| Task | Pattern | Location | +|------|---------|----------| +| Create event | `eventTemplate(kind, content, tags)` | nip01Core/signers/ | +| Build tags | `tagArray { add(...) }` | nip01Core/core/ | +| Sign event | `signer.sign(template)` | nip01Core/signers/ | +| Verify signature | `event.verify()` | nip01Core/core/ | +| Encrypt (NIP-44) | `Nip44v2.encrypt(...)` | nip44Encryption/ | +| Bech32 encode | `Nip19.npubEncode(...)` | nip19Bech32/ | +| Find NIP | `scripts/nip-lookup.sh ` | - | + +## Common Event Kinds + +| Kind | Type | NIP | Package | +|------|------|-----|---------| +| 0 | Metadata | 01 | nip01Core/ | +| 1 | Text note | 01, 10 | nip10Notes/ | +| 3 | Contact list | 02 | nip02FollowList/ | +| 5 | Deletion | 09 | nip09Deletions/ | +| 7 | Reaction | 25 | nip25Reactions/ | +| 1059 | Gift wrap | 59 | nip59Giftwrap/ | +| 9734 | Zap request | 57 | nip57Zaps/ | +| 9735 | Zap receipt | 57 | nip57Zaps/ | +| 10002 | Relay list | 65 | nip65RelayList/ | +| 30023 | Long-form content | 23 | nip23LongContent/ | + +## Related Skills + +- **nostr-protocol** - NIP specifications and protocol details +- **kotlin-expert** - Kotlin patterns (@Immutable, sealed classes, DSLs) +- **kotlin-coroutines** - Async patterns for relay communication +- **kotlin-multiplatform** - KMP patterns, expect/actual in Quartz diff --git a/.claude/skills/nostr-expert/references/event-hierarchy.md b/.claude/skills/nostr-expert/references/event-hierarchy.md new file mode 100644 index 0000000000..1ca0c0fa1c --- /dev/null +++ b/.claude/skills/nostr-expert/references/event-hierarchy.md @@ -0,0 +1,293 @@ +# Event Hierarchy & Structure + +## Core Hierarchy + +``` +IEvent (empty interface) + └── Event (@Immutable base class) + ├── BaseAddressableEvent (replaceable + addressable, has d-tag) + │ ├── BaseReplaceableEvent (kinds 10000-20000, FIXED_D_TAG = "") + │ └── [Specific addressable events - 30000-40000] + └── [Specific event implementations - all other kinds] +``` + +## Event Base Class + +**Location**: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt` + +```kotlin +@Immutable +open class Event( + val id: HexKey, // SHA-256 hash of serialized event + val pubKey: HexKey, // Author's public key (32 bytes hex) + val createdAt: Long, // Unix timestamp + val kind: Kind, // Event kind (Int typealias) + val tags: TagArray, // Array of tag arrays + val content: String, // Event content + val sig: HexKey, // schnorr signature (64 bytes hex) +) : IEvent, OptimizedSerializable +``` + +## Kind Classification + +```kotlin +typealias Kind = Int + +fun Kind.isEphemeral() = this in 20000..29999 +fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999 +fun Kind.isAddressable() = this in 30000..39999 +fun Kind.isRegular() = this in 1000..9999 +``` + +## Common Event Types + +### Text Note (kind 1) +```kotlin +class TextNoteEvent(...) : BaseThreadedEvent(...), + EventHintProvider, AddressHintProvider, PubKeyHintProvider, SearchableEvent + +// Threading support via markers: reply, root, mention +fun replyTo(): List // Direct reply targets +fun root(): Note? // Root of thread +``` + +### Metadata (kind 0) +```kotlin +class MetadataEvent(...) : BaseAddressableEvent(...) + +// Replaceable: newest version overwrites old +// d-tag automatically set to "" for kind 0 +fun name(): String? +fun displayName(): String? +fun picture(): String? +fun about(): String? +fun lnAddress(): String? +``` + +### Reaction (kind 7) +```kotlin +class ReactionEvent(...) : Event(...) + +companion object { + const val LIKE = "+" + const val DISLIKE = "-" + + fun like(reactedTo: EventHintBundle, ...) + fun dislike(reactedTo: EventHintBundle, ...) +} +``` + +### Zap Request/Receipt (kinds 9734, 9735) +```kotlin +class LnZapRequestEvent(...) : Event(...) + // Created by client, sent to Lightning Address + +class LnZapEvent(...) : Event(...) + // Receipt from LSP, contains bolt11 + embedded zap request + val zapRequest: LnZapRequestEvent? by lazy { containedPost() } + val amount: BigDecimal? by lazy { /* parse from bolt11 */ } +``` + +### Long-Form Content (kind 30023) +```kotlin +class LongTextNoteEvent(...) : BaseAddressableEvent(...) + // Blog posts, articles + // Addressable via kind:pubkey:d-tag +``` + +### Lists (kinds 10000-30004) +```kotlin +sealed class PeopleListEvent : BaseAddressableEvent { + object MuteList : PeopleListEvent(10000) + object PinList : PeopleListEvent(10001) + object BookmarkList : PeopleListEvent(10003) + // ... 18 list types total +} +``` + +## Event Interfaces + +### Hint Providers +Events can implement interfaces to optimize relay queries: + +```kotlin +interface EventHintProvider { + fun taggedEventIds(): Set + fun taggedEventRelays(): Map> +} + +interface PubKeyHintProvider { + fun taggedPubKeys(): Set + fun taggedPubKeyRelays(): Map> +} + +interface AddressHintProvider { + fun taggedAddresses(): Set
+ fun taggedAddressRelays(): Map> +} + +interface SearchableEvent { + fun subject(): String? + fun isContentEncoded(): Boolean +} +``` + +## Event Building Pattern + +### DSL Builder +```kotlin +TextNoteEvent.build( + note = "Hello Nostr", + replyingTo = eventBundle, + createdAt = TimeUtils.now() +) { + pTag(pubKey, relayHint) // Tag person + eTag(eventId, relayHint, "reply") // Tag event with marker + hashtag("nostr") // Add hashtag + alt("A short note") // Alt text +} +``` + +### Event Template (Low-level) +```kotlin +suspend fun eventTemplate( + kind: Kind, + content: String, + createdAt: Long, + initializer: TagArrayBuilder.() -> Unit +): EventTemplate { + val tags = TagArrayBuilder().apply(initializer).build() + return EventTemplate(kind, tags, content, createdAt) +} + +// Sign with signer +val template = eventTemplate(1, "Hello", now()) { pTag(pubkey) } +val signedEvent = signer.sign(template) +``` + +## Addressable vs Regular Events + +| Feature | Regular Event | Addressable Event | +|---------|---------------|-------------------| +| **Identifier** | Event ID (SHA-256 hash) | Address (kind:pubkey:d-tag) | +| **Replaceability** | Immutable | Newest replaces old | +| **d-tag** | Optional | Required | +| **Lookup** | By event ID | By address | +| **Example** | Text note (kind 1) | Metadata (kind 0), Long-form (kind 30023) | + +```kotlin +// Regular event address +note = LocalCache.getNoteIfExists(eventId) + +// Addressable event address +address = Address(kind = 30023, pubkey = authorHex, dTag = "my-article") +note = LocalCache.getAddressableNoteIfExists(address) +``` + +## Event Validation + +```kotlin +// Verify event ID matches computed hash +fun Event.verifyId(): Boolean = + EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content) + +// Verify signature +fun Event.verifySignature(): Boolean = + Nip01.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) + +// Complete verification +fun Event.checkSignature() { + if (!verifyId()) throw Exception("ID mismatch") + if (!verifySignature()) throw Exception("Bad signature!") +} +``` + +## Event Serialization + +```kotlin +// To JSON (for transmission/signing) +fun Event.toJson(): String = OptimizedJsonMapper.toJson(this) + +// From JSON +fun Event.fromJson(json: String): Event = OptimizedJsonMapper.fromJson(json) + +// Event ID generation (SHA-256 of canonical JSON) +fun EventHasher.hashId( + pubKey: HexKey, + createdAt: Long, + kind: Kind, + tags: TagArray, + content: String +): HexKey { + val serialized = """[0,"$pubKey",$createdAt,$kind,${tags.toJson()},"$content"]""" + return sha256(serialized.encodeToByteArray()).toHexKey() +} +``` + +## Event Lifecycle in LocalCache + +``` +Event received from relay + ↓ +LocalCache.consume(event, relay, wasVerified) + ↓ +getOrCreateNote(event.id) or getOrCreateAddressableNote(address) + ↓ +justVerify(event) → checkSignature() + ↓ +note.loadEvent(event, author, replyTo) + ↓ +Update indices (replies, reactions, boosts) + ↓ +refreshNewNoteObservers(note) → emit to SharedFlow + ↓ +UI updates +``` + +## Common Event Patterns + +### Reply Threading +```kotlin +// Root event (top of thread) +val rootEvent = TextNoteEvent.build("Thread root") { } + +// Reply to root +val reply1 = TextNoteEvent.build("First reply", replyingTo = rootEvent) { + // Automatically adds: + // ["e", , , "root"] + // ["e", , , "reply"] +} + +// Reply to reply (nested) +val reply2 = TextNoteEvent.build("Nested reply", replyingTo = reply1) { + // Automatically adds: + // ["e", , , "root"] + // ["e", , , "reply"] +} +``` + +### Replaceable Events +```kotlin +// Metadata update (kind 0) - newest wins +val metadata1 = MetadataEvent.createNew(name = "Alice", picture = "url1") +Thread.sleep(1000) +val metadata2 = MetadataEvent.createNew(name = "Alice Updated", picture = "url2") + +// LocalCache keeps only metadata2 (higher createdAt) +``` + +### Event Deletion +```kotlin +// Delete events +val deletion = DeletionEvent.create( + deleteEvents = listOf(eventId1, eventId2), + reason = "Spam", + signer = signer +) + +// LocalCache marks events as deleted, but doesn't remove (for verification) +``` + +## 63+ Event Classes + +Full list at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip*/` - one class per event type across 60+ NIP implementations. diff --git a/.claude/skills/nostr-expert/references/nip-catalog.md b/.claude/skills/nostr-expert/references/nip-catalog.md new file mode 100644 index 0000000000..950f2cb1a1 --- /dev/null +++ b/.claude/skills/nostr-expert/references/nip-catalog.md @@ -0,0 +1,179 @@ +# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz + +## Standard NIPs by Category + +### Core/Basic Protocol +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 01 | `nip01Core/` | Event.kt, Kind.kt, Tag.kt | Core protocol, event structure, kinds, tags | +| 02 | `nip02FollowList/` | ContactListEvent.kt | Follow/contact lists (kind 3) | +| 03 | `nip03Timestamp/` | OpenTimestampsAttestation.kt | Timestamps | +| 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) | +| 05 | `nip05DnsIdentifiers/` | Nip05Verifier.kt | DNS-based verification | +| 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation | +| 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) | +| 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata | +| 13 | `nip13Pow/` | ProofOfWork.kt | Proof of work | +| 14 | `nip14Subject/` | Subject tags | Subject tags for text notes | +| 17 | `nip17Dm/` | GiftWrapEvent.kt, SealedGossipEvent.kt | Private DMs (replacem + +ent for NIP-04) | +| 21 | `nip21UriScheme/` | URI scheme (`nostr:`) | URI scheme parsing | +| 42 | `nip42RelayAuth/` | RelayAuthEvent.kt | Relay authentication (kind 22242) | +| 44 | `nip44Encryption/` | Nip44.kt, Nip44v2.kt | Modern encryption (ChaCha20) | +| 49 | `nip49PrivKeyEnc/` | NIP-49Ncryptsec.kt | Private key encryption format | + +### Content Types +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 10 | `nip10Notes/` | TextNoteEvent.kt | Text notes with threading (kind 1) | +| 18 | `nip18Reposts/` | RepostEvent.kt, GenericRepostEvent.kt | Reposts (kind 6, 16) | +| 22 | `nip22Comments/` | CommentEvent.kt | Comments (kind 1111) | +| 23 | `nip23LongContent/` | LongTextNoteEvent.kt | Long-form content (kind 30023) | +| 25 | `nip25Reactions/` | ReactionEvent.kt | Reactions (kind 7) | +| 31 | `nip31Alts/` | Alt tags | Alt description tags | +| 36 | `nip36SensitiveContent/` | Content warnings | Content warning tags | +| 37 | `nip37Drafts/` | DraftEvent.kt | Drafts (kind 31234) | +| 50 | `nip50Search/` | Search filters | Full-text search | + +### Encoding & Standards +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 19 | `nip19Bech32/` | Nip19.kt | Bech32 encoding (npub, nsec, note, nevent, nprofile, naddr) | +| 40 | `nip40Expiration/` | Expiration tags | Event expiration | +| 48 | `nip48ProxyTags/` | Proxy tags | Proxy tags for delegation | +| 62 | `nip62RequestToVanish/` | RequestToVanishEvent.kt | Request to vanish (kind 12) | +| 98 | `nip98HttpAuth/` | HTTP authorization | HTTP auth header | + +### Lists & Management +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 51 | `nip51Lists/` | 18 list types | Named lists (mute, bookmarks, pins, communities, etc.) (kinds 10000-30004) | +| 65 | `nip65RelayList/` | AdvertisedRelayListEvent.kt | Relay lists (kind 10002) | + +### Social & Identity +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 39 | `nip39ExtIdentities/` | External identities | External identity claims | +| 46 | `nip46RemoteSigner/` | NostrConnectEvent.kt | Remote signer protocol (bunker) | +| 47 | `nip47WalletConnect/` | Nostr Wallet Connect | Wallet connection protocol | +| 56 | `nip56Reports/` | ReportEvent.kt | Reports (kind 1984) | +| 57 | `nip57Zaps/` | LnZapEvent.kt, LnZapRequestEvent.kt | Lightning zaps (kinds 9734, 9735) | +| 58 | `nip58Badges/` | Badge events | Badge definitions & awards (kinds 30009, 8) | +| 59 | `nip59Giftwrap/` | GiftWrapEvent.kt | Gift-wrapped events for privacy | +| 75 | `nip75ZapGoals/` | ZapGoalEvent.kt | Zap goals (kind 9041) | + +### Specialized Content +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 28 | `nip28PublicChat/` | ChannelCreateEvent.kt, ChannelMessageEvent.kt | Public chat channels (kinds 40-44) | +| 30 | `nip30CustomEmoji/` | EmojiUrl.kt | Custom emoji | +| 34 | `nip34Git/` | Git patch/issue events | Git repository tracking (kinds 30617, 30618, 1617, 1621, 1622, 1630, 1633) | +| 35 | `nip35Torrents/` | Torrent events | Torrent tracking | +| 52 | `nip52Calendar/` | Calendar events | Calendar time-based/date-based (kinds 31922-31925) | +| 53 | `nip53LiveActivities/` | LiveActivitiesEvent.kt | Live events/streaming (kind 30311) | +| 54 | `nip54Wiki/` | WikiNoteEvent.kt | Wiki pages (kind 30818) | +| 68 | `nip68Picture/` | Picture metadata | Picture metadata | +| 71 | `nip71Video/` | 7 video event types | Video events (kinds 34235, 35235, 1234, 1235) | +| 72 | `nip72ModCommunities/` | Community events | Moderated communities (kinds 34550, 34551, 9041) | +| 84 | `nip84Highlights/` | HighlightEvent.kt | Highlights (kind 9802) | +| 89 | `nip89AppHandlers/` | AppDefinitionEvent.kt | App recommendations (kinds 31990, 31989) | +| 90 | `nip90Dvms/` | DVM job events | Data Vending Machines (DVMs) (kinds 5000-7000) | +| 92 | `nip92IMeta/` | IMeta tags | Image metadata tags | +| 94 | `nip94FileMetadata/` | FileHeaderEvent.kt, FileStorageEvent.kt | File metadata (kind 1063) | +| 96 | `nip96FileStorage/` | HTTP file storage | HTTP-based file storage | +| 99 | `nip99Classifieds/` | ClassifiedsEvent.kt | Classifieds/marketplace (kind 30402) | +| A0 | `nipA0VoiceMessages/` | Voice messages | Voice message events | +| B7 | `nipB7Blossom/` | Blossom server URLs | Blossom file storage | + +### Web/Storage/Other +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 38 | `nip38UserStatus/` | StatusEvent.kt | User status (kind 30315) | +| 60 | `nip60Payment/` | Wallet events | Wallet info (kind 13194) | +| 61 | `nip61PaymentRequest/` | Nut zaps | Cashu payment requests | +| 64 | `nip64Chess/` | Chess moves | Chess move events | +| 66 | `nip66Monitoring/` | Relay monitor events | Relay monitoring | +| 67 | `nip67Invoices/` | Invoice tags | Lightning invoice tags | +| 69 | `nip69Offers/` | BOLT-12 offers | BOLT-12 offer tags | +| 70 | `nip70ProtectedEvts/` | Protected events | Protected event types | +| 73 | `nip73ExternalIds/` | External content IDs | External content identifiers | +| 78 | `nip78AppData/` | AppDataEvent.kt | Application data (kind 30078) | +| 79 | `nip79Labels/` | Label events | Labeling (kinds 1985, 1986) | +| 80-88 | Various | Various protocols | Relationship, preferences, polls, surveys, social graphs, etc. | +| 91 | `nip91Feed/` | Feed display events | Feed definitions | +| 93 | `nip93Gallery/` | Gallery events | Gallery collections | +| 95 | `nip95Storage/` | Storage event tags | Storage events | +| 97 | `nip97Nests/` | Audio rooms | Audio room events | + +## Experimental NIPs (18 packages) + +Located at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/`: + +| Package | Description | +|---------|-------------| +| `audio/` | Audio content, track events | +| `bounties/` | Bounty/funding events | +| `decoupling/` | Decoupling setup | +| `edits/` | Event edit tracking | +| `ephemChat/` | Ephemeral encrypted chat | +| `forks/` | Fork tracking | +| `inlineMetadata/` | Inline metadata | +| `interactiveStories/` | Interactive story events | +| `limits/` | Limit enforcement | +| `medical/` | Medical data | +| `nip95/` | File storage support | +| `nipA3/` | A3 protocol extension | +| `nns/` | Nostr Name System | +| `profileGallery/` | Profile gallery lists | +| `publicMessages/` | Public message lists | +| `relationshipStatus/` | Relationship status events | +| `trustedAssertions/` | Trust/assertion events | +| `zapPolls/` | Zap-based polling | + +## Quick Lookup by Kind + +| Kind | Event Type | NIP | +|------|------------|-----| +| 0 | Metadata | 01 | +| 1 | Text Note | 01, 10 | +| 3 | Follow List | 02 | +| 4 | Encrypted DM (legacy) | 04 | +| 5 | Deletion | 09 | +| 6 | Repost | 18 | +| 7 | Reaction | 25 | +| 8 | Badge Award | 58 | +| 16 | Generic Repost | 18 | +| 40-44 | Channel Events | 28 | +| 1063 | File Metadata | 94 | +| 1111 | Comment | 22 | +| 1617, 1621, 1622, 1630, 1633 | Git | 34 | +| 1984 | Report | 56 | +| 1985, 1986 | Label | 79 | +| 9734 | Zap Request | 57 | +| 9735 | Zap Receipt | 57 | +| 9802 | Highlight | 84 | +| 10000-20000 | Replaceable Lists | 51 | +| 10002 | Relay List | 65 | +| 13194 | Wallet Info | 60 | +| 22242 | Relay Auth | 42 | +| 23194, 23195 | NWC Payment | 47 | +| 30000-40000 | Addressable Events | Various | +| 30009 | Badge Definition | 58 | +| 30023 | Long-Form Content | 23 | +| 30078 | App Data | 78 | +| 30311 | Live Event | 53 | +| 30315 | User Status | 38 | +| 30402 | Classifieds | 99 | +| 30818 | Wiki | 54 | +| 31234 | Draft | 37 | +| 31922-31925 | Calendar | 52 | +| 31989, 31990 | App Handlers | 89 | +| 34235, 34550-34551 | Video/Communities | 71, 72 | +| 5000-7000 | DVM Jobs | 90 | + +## File Location Pattern + +All NIPs located at: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip/` + +Example: NIP-57 → `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/` diff --git a/.claude/skills/nostr-expert/references/tag-patterns.md b/.claude/skills/nostr-expert/references/tag-patterns.md new file mode 100644 index 0000000000..9279a9c22a --- /dev/null +++ b/.claude/skills/nostr-expert/references/tag-patterns.md @@ -0,0 +1,336 @@ +# Tag Patterns in Quartz + +Tags are the primary way events reference other events, users, and metadata in Nostr. + +## Tag Structure + +```kotlin +typealias Tag = Array // ["tag_name", "value", "optional_param", ...] +typealias TagArray = Array +``` + +**Pattern**: `[name, value, ...optionalParams]` + +## TagArrayBuilder DSL + +```kotlin +fun tagArray(initializer: TagArrayBuilder.() -> Unit): TagArray +``` + +**Methods**: +- `add(tag)` - Append tag +- `addFirst(tag)` - Prepend tag +- `addUnique(tag)` - Replace all tags with this name +- `remove(tagName)` - Remove all tags with name +- `removeIf(predicate, toCompare)` - Conditional removal + +**Example**: +```kotlin +val tags = tagArray { + add(arrayOf("e", eventId, relayHint, "reply")) + add(arrayOf("p", pubkey)) + addUnique(arrayOf("subject", "Hello")) +} +``` + +## Core Tag Types (NIP-01) + +### e-tag (Event Reference) +```kotlin +// ["e", , , ] +arrayOf("e", eventId, "wss://relay.damus.io", "reply") +``` + +**Markers** (NIP-10): +- `root` - Root of thread +- `reply` - Direct reply target +- `mention` - Mentioned event (not reply) + +**Extensions**: +```kotlin +// nip01Core/tags/ +fun TagArrayBuilder.eTag(eventId: HexKey, relay: String? = null, marker: String? = null) +``` + +### p-tag (Pubkey Reference) +```kotlin +// ["p", , ] +arrayOf("p", pubkey, "wss://relay.damus.io") +``` + +**Usage**: Tag users, indicate recipients + +**Extensions**: +```kotlin +fun TagArrayBuilder.pTag(pubkey: HexKey, relay: String? = null) +``` + +### a-tag (Addressable Event Reference) +```kotlin +// ["a", ::, ] +arrayOf("a", "30023:${authorPubkey}:${dtag}", "wss://relay.damus.io") +``` + +**Usage**: Reference replaceable/addressable events (kinds 10000-20000, 30000-40000) + +**Extensions**: +```kotlin +fun TagArrayBuilder.aTag(kind: Int, pubkey: HexKey, dTag: String, relay: String? = null) +``` + +### d-tag (Identifier) +```kotlin +// ["d", ] +arrayOf("d", "my-article-slug") +``` + +**Usage**: Unique identifier for addressable events + +## Common Tag Extensions + +### Subject (NIP-14) +```kotlin +// nip14Subject/ +fun Event.subject(): String? +fun TagArrayBuilder.subject(text: String) +``` + +### Content Warning (NIP-36) +```kotlin +// nip36SensitiveContent/ +fun Event.contentWarning(): String? +fun TagArrayBuilder.contentWarning(reason: String = "") +``` + +### Expiration (NIP-40) +```kotlin +// nip40Expiration/ +fun Event.expiration(): Long? +fun TagArrayBuilder.expiration(unixTimestamp: Long) +``` + +### Alt Description (NIP-31) +```kotlin +// nip31Alts/ +fun Event.alt(): String? +fun TagArrayBuilder.alt(description: String) +``` + +## Specialized Tags + +### Zap Tags (NIP-57) +```kotlin +// nip57Zaps/tags/ +class BoltTag(val bolt11: String, val preimage: String?) +class DescriptionTag(val zapRequestJson: String) +``` + +### Imeta Tags (NIP-92) +```kotlin +// nip92IMeta/ +class IMetaTag(val url: String, val metadata: Map) + +// Usage: Image metadata +IMetaTag("https://example.com/image.jpg", mapOf( + "m" to "image/jpeg", + "dim" to "1920x1080", + "blurhash" to "..." +)) +``` + +### Relay Tags (NIP-65) +```kotlin +// nip65RelayList/ +class RelayTag(val url: String, val type: RelayType) +enum class RelayType { READ, WRITE, BOTH } +``` + +## Tag Query Patterns + +### Finding Tags +```kotlin +// Extension functions on TagArray +fun TagArray.firstTag(name: String): Tag? +fun TagArray.allTags(name: String): List +fun TagArray.tagValue(name: String): String? +fun TagArray.tagValues(name: String): List +``` + +**Example**: +```kotlin +val event: TextNoteEvent = ... +val subject = event.tags.tagValue("subject") +val mentions = event.tags.allTags("p").mapNotNull { it.getOrNull(1) } +``` + +### Parsing Tags +```kotlin +// Pattern: Companion object with parse methods +object ETag { + fun parse(tag: Tag): ETag? { + if (tag.getOrNull(0) != "e") return null + return ETag( + eventId = tag.getOrNull(1) ?: return null, + relay = tag.getOrNull(2), + marker = tag.getOrNull(3) + ) + } +} + +// Usage +val eTags = event.tags.mapNotNull(ETag::parse) +``` + +## Event Builder Pattern + +Combining TagArrayBuilder with event creation: + +```kotlin +fun createTextNote(content: String, replyTo: Event?): EventTemplate { + return eventTemplate( + kind = 1, + content = content, + tags = tagArray { + replyTo?.let { + eTag(it.id, marker = "reply") + pTag(it.pubKey) + it.rootEvent()?.let { root -> + eTag(root.id, marker = "root") + } + } + } + ) +} +``` + +## Hint System + +Tags can provide "hints" - optional relay URLs for fetching referenced content: + +```kotlin +// Event references +["e", eventId, "wss://relay.example.com"] // relay hint + +// Pubkey references +["p", pubkey, "wss://relay.example.com"] // relay hint + +// Addressable references +["a", "30023:pubkey:dtag", "wss://relay.example.com"] // relay hint +``` + +**Pattern**: Third parameter (index 2) is always the relay hint + +## Tag Validation + +```kotlin +// Common validations +fun validateETag(tag: Tag): Boolean { + return tag.getOrNull(0) == "e" && tag.getOrNull(1)?.isValidHex() == true +} + +fun validatePTag(tag: Tag): Boolean { + return tag.getOrNull(0) == "p" && tag.getOrNull(1)?.isValidHex() == true +} +``` + +## Performance Patterns + +### Tag Indexing +```kotlin +// TagArrayBuilder keeps an index by tag name +private val tagList = mutableMapOf>() + +// Fast lookup by name +fun remove(tagName: String) { + tagList.remove(tagName) +} +``` + +### Lazy Parsing +```kotlin +// Don't parse all tags upfront +class TextNoteEvent(...) { + private val _mentions by lazy { + tags.mapNotNull(PTag::parse) + } + + fun mentions() = _mentions +} +``` + +## Common Workflows + +### Creating a Reply +```kotlin +fun replyTo(original: TextNoteEvent, content: String): EventTemplate { + return eventTemplate( + kind = 1, + content = content, + tags = tagArray { + // Reply to this event + eTag(original.id, marker = "reply") + + // Copy root marker if exists, or mark original as root + original.rootEvent()?.let { + eTag(it.id, marker = "root") + } ?: eTag(original.id, marker = "root") + + // Tag author + pTag(original.pubKey) + + // Tag all mentioned users + original.mentions().forEach { pTag(it) } + } + ) +} +``` + +### Creating a Reaction +```kotlin +fun createReaction(targetEvent: Event, emoji: String): EventTemplate { + return eventTemplate( + kind = 7, + content = emoji, + tags = tagArray { + eTag(targetEvent.id) + pTag(targetEvent.pubKey) + } + ) +} +``` + +### Creating an Addressable Event +```kotlin +fun createArticle(title: String, content: String, slug: String): EventTemplate { + return eventTemplate( + kind = 30023, + content = content, + tags = tagArray { + addUnique(arrayOf("d", slug)) // Unique identifier + add(arrayOf("title", title)) + add(arrayOf("published_at", "${TimeUtils.now()}")) + } + ) +} +``` + +## Quick Reference + +| Tag | NIP | Usage | Example | +|-----|-----|-------|---------| +| e | 01 | Event reference | `["e", eventId, relay, marker]` | +| p | 01 | Pubkey reference | `["p", pubkey, relay]` | +| a | 01 | Addressable event | `["a", "kind:pubkey:d"]` | +| d | 01 | Identifier | `["d", "unique-id"]` | +| subject | 14 | Subject line | `["subject", "Hello"]` | +| content-warning | 36 | Content warning | `["content-warning", "nsfw"]` | +| expiration | 40 | Expiration time | `["expiration", "1234567890"]` | +| bolt11 | 57 | Lightning invoice | `["bolt11", "lnbc..."]` | +| imeta | 92 | Media metadata | `["imeta", "url", "m", "image/jpeg"]` | +| relay | 65 | User relays | `["relay", "wss://...", "read"]` | + +## Resources + +- Tag builders: `quartz/src/commonMain/.../nip01Core/tags/` +- Tag extensions: Look for `TagArrayExt.kt`, `TagArrayBuilderExt.kt` in each NIP package +- Event parsing: Each event class has tag parsing methods diff --git a/.claude/skills/nostr-expert/scripts/nip-lookup.sh b/.claude/skills/nostr-expert/scripts/nip-lookup.sh new file mode 100755 index 0000000000..1270bc3181 --- /dev/null +++ b/.claude/skills/nostr-expert/scripts/nip-lookup.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Find NIP implementation files by NIP number or search term + +set -e + +QUARTZ_PATH="${QUARTZ_PATH:-./quartz/src/commonMain/kotlin/com/vitorpamplona/quartz}" + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + echo "" + echo "Examples:" + echo " $0 01 # Find NIP-01 files" + echo " $0 44 # Find NIP-44 files" + echo " $0 encryption # Search for 'encryption' in NIP packages" + echo "" + echo "Set QUARTZ_PATH to override default quartz location" + exit 1 +fi + +SEARCH_TERM="$1" + +# Check if it's a number (NIP number) +if [[ "$SEARCH_TERM" =~ ^[0-9]+$ ]]; then + # Pad to 2 digits + NIP_NUM=$(printf "%02d" "$SEARCH_TERM") + echo "Searching for NIP-$NIP_NUM implementation..." + echo "================================================" + echo "" + + # Find directories matching nip##* + find "$QUARTZ_PATH" -type d -name "nip${NIP_NUM}*" | while read -r dir; do + echo "📁 $(basename "$dir")/" + find "$dir" -name "*.kt" -type f | while read -r file; do + rel_path="${file#$QUARTZ_PATH/}" + echo " └─ $rel_path" + done + echo "" + done +else + # Text search + echo "Searching for '$SEARCH_TERM' in NIP packages..." + echo "================================================" + echo "" + + find "$QUARTZ_PATH" -type d -name "nip*" | while read -r dir; do + if grep -r -l -i "$SEARCH_TERM" "$dir" --include="*.kt" 2>/dev/null | head -1 > /dev/null; then + echo "📁 $(basename "$dir")/" + grep -r -l -i "$SEARCH_TERM" "$dir" --include="*.kt" 2>/dev/null | while read -r file; do + rel_path="${file#$QUARTZ_PATH/}" + matches=$(grep -c -i "$SEARCH_TERM" "$file" 2>/dev/null || echo "0") + echo " └─ $rel_path ($matches matches)" + done + echo "" + fi + done +fi + +echo "Done."