Browse Source

iso maker

Milan Jurkulak 3 tháng trước cách đây
mục cha
commit
1d5619f8cc
36 tập tin đã thay đổi với 6364 bổ sung và 14 xóa
  1. 3 1
      .claude/settings.local.json
  2. 564 0
      .claude/skills/clean-architecture/SKILL.md
  3. 376 0
      .claude/skills/clean-code/SKILL.md
  4. 325 0
      .claude/skills/compose-skill/scripts/validate.sh
  5. 474 0
      .claude/skills/coroutines-flow/SKILL.md
  6. 452 0
      .claude/skills/dependency-injection/SKILL.md
  7. 582 0
      .claude/skills/jetpack-compose/SKILL.md
  8. 400 0
      .claude/skills/kmp-patterns/SKILL.md
  9. 37 0
      .claude/skills/mjdev-code-conventions/SKILL.md
  10. 48 0
      .claude/skills/mjdev-package-dependencies/SKILL.md
  11. 49 0
      .claude/skills/mjdev-privilege-escalation/SKILL.md
  12. 85 0
      .claude/skills/mjdev-qemu-test/SKILL.md
  13. 90 0
      .claude/skills/mjdev-qemu-test/qmp_drive.py
  14. 67 0
      .claude/skills/mjdev-qemu-test/test-desktop.sh
  15. 714 0
      .claude/skills/modularization/SKILL.md
  16. 805 0
      .claude/skills/solid-android/SKILL.md
  17. 574 0
      .claude/skills/tdd-android/SKILL.md
  18. 9 1
      .github/workflows/release.yml
  19. 3 0
      .gitignore
  20. 31 0
      .run/makeIso.run.xml
  21. 31 0
      .run/runIsoQemu.run.xml
  22. 6 4
      ai-todo.txt
  23. 81 1
      build.gradle.kts
  24. 127 0
      buildSrc/src/main/kotlin/PackageFullDebTask.kt
  25. 24 2
      compositor/compositor.gradle.kts
  26. 5 0
      deb-packages/README.md
  27. BIN
      deb-packages/cursor-theme-mjdev.deb
  28. BIN
      deb-packages/plymouth-theme-mjdev-text.deb
  29. BIN
      deb-packages/plymouth-theme-mjdev.deb
  30. BIN
      deb-packages/sound-theme-mjdev.deb
  31. 5 0
      gradle/libs.versions.toml
  32. 297 0
      make-iso.sh
  33. 79 0
      run-iso-qemu.sh
  34. 18 1
      session/mjdev-session
  35. 1 2
      shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/processes/ProcessManager.kt
  36. 2 2
      shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/ChromeWindowState.kt

+ 3 - 1
.claude/settings.local.json

@@ -36,7 +36,9 @@
       "WebFetch(domain:blog.jetbrains.com)",
       "Bash(timeout 580 ./gradlew :shared:compileKotlinDesktop -q)",
       "Bash(xargs ls -la)",
-      "Bash(./gradlew :compositor:help --task installDesktop -q)"
+      "Bash(./gradlew :compositor:help --task installDesktop -q)",
+      "Bash(git ls-tree *)",
+      "Bash(grep -iE \"iso|live-build|debootstrap|\\\\.sh$\")"
     ]
   }
 }

+ 564 - 0
.claude/skills/clean-architecture/SKILL.md

@@ -0,0 +1,564 @@
+---
+name: clean-architecture
+description: Structure Android/KMP projects in clean architecture layers — domain, data, presentation — with strict dependency inversion, layer contracts, and error propagation rules
+argument-hint: "<feature or layer to design>"
+user-invocable: true
+allowed-tools: ["Read", "Write", "Edit", "Glob", "Grep"]
+---
+
+# Clean Architecture — Android / KMP
+
+## Layer Overview
+
+```
+┌──────────────────────────────────────────┐
+│         Presentation (UI)                │
+│  Composables · ViewModels · Navigation   │
+├──────────────────────────────────────────┤
+│           Domain (Core)                  │
+│  Use Cases · Models · Repo Interfaces    │
+├──────────────────────────────────────────┤
+│              Data                        │
+│  Repo Impls · DataSources · DTOs/Entities│
+├──────────────────────────────────────────┤
+│         Framework / Platform             │
+│  Room · Ktor · SQLDelight · Firebase     │
+└──────────────────────────────────────────┘
+       Dependencies point INWARD only
+```
+
+**The Dependency Rule**: source code dependencies can only point toward the center. Domain knows nothing about data, presentation, or any framework.
+
+---
+
+## Core Utilities (`:core:common`)
+
+Define these shared types once. All layers depend on `:core:common`.
+
+### Try\<T\>
+
+```kotlin
+// core/common/Try.kt
+sealed class Try<out T> {
+    data class Success<T>(val value: T) : Try<T>()
+    data class Failure(val error: Throwable) : Try<Nothing>()
+
+    fun getOrThrow(): T = when (this) {
+        is Success -> value
+        is Failure -> throw error
+    }
+
+    fun <R> map(transform: (T) -> R): Try<R> = when (this) {
+        is Success -> tryOf { transform(value) }
+        is Failure -> this
+    }
+
+    fun reduce(onSuccess: (T) -> Unit, onFailure: (Throwable) -> Unit) = when (this) {
+        is Success -> onSuccess(value)
+        is Failure -> onFailure(error)
+    }
+}
+
+suspend fun <T> tryOf(block: suspend () -> T): Try<T> = try {
+    Try.Success(block())
+} catch (e: Exception) {
+    Try.Failure(e)
+}
+
+fun <T> tryOf(block: () -> T): Try<T> = try {
+    Try.Success(block())
+} catch (e: Exception) {
+    Try.Failure(e)
+}
+```
+
+### Use Case Base Classes
+
+```kotlin
+// core/common/usecase/UseCase.kt
+interface UseCase<in P, out T> {
+    suspend operator fun invoke(params: P): Try<T>
+}
+
+// core/common/usecase/FlowUseCase.kt
+interface FlowUseCase<in P, out T> {
+    operator fun invoke(params: P): Flow<T>
+}
+```
+
+### BaseViewModel
+
+```kotlin
+// core/common/viewmodel/BaseViewModel.kt
+abstract class BaseViewModel<S, E> : ViewModel() {
+    private val _state: MutableStateFlow<S> by lazy { MutableStateFlow(initialState()) }
+    val state: StateFlow<S> get() = _state.asStateFlow()
+
+    private val _effects = MutableSharedFlow<E>(extraBufferCapacity = 16)
+    val effects: SharedFlow<E> = _effects.asSharedFlow()
+
+    abstract fun initialState(): S
+
+    protected fun updateState(update: S.() -> S) {
+        _state.update { it.update() }
+    }
+
+    protected fun emitEffect(effect: E) {
+        viewModelScope.launch { _effects.emit(effect) }
+    }
+}
+
+// Prefer this over collectAsStateWithLifecycle()
+@Composable
+fun <S, E> BaseViewModel<S, E>.collectState(): State<S> = state.collectAsState()
+```
+
+---
+
+## Domain Layer — Pure Kotlin
+
+No Android SDK, no Ktor, no Room, no Compose. Only pure Kotlin and `kotlinx` libraries.
+
+### Domain Models
+
+```kotlin
+// domain/model/Word.kt
+data class Word(
+    val id: Int,
+    val original: String,
+    val translated: String,
+    val bucket: Int,
+    val nextReviewDate: LocalDate,
+    val createdAt: Instant,
+)
+
+// Pass today as a parameter — keeps the function pure and testable
+fun Word.isDue(today: LocalDate): Boolean = nextReviewDate <= today
+```
+
+### Repository Interfaces (in domain)
+
+```kotlin
+// domain/repository/IWordRepository.kt
+interface IWordRepository {
+    fun observeWords(): Flow<List<Word>>         // stream — never Flow<Try<T>>
+    suspend fun findById(id: Int): Try<Word>     // one-shot — always Try<T>
+    suspend fun save(word: Word): Try<Word>
+    suspend fun delete(id: Int): Try<Unit>
+    suspend fun syncWithRemote(): Try<Unit>
+}
+```
+
+Rules:
+- Interface lives in `domain`; implementation lives in `data`
+- Suspend ops return `Try<T>` — never throw
+- Stream ops return `Flow<T>` — never `Flow<Try<T>>`
+- No framework types in signatures (`Response<T>`, `Entity`, `Cursor`)
+
+### Use Cases
+
+```kotlin
+// domain/usecase/GetDueWordsUseCase.kt
+class GetDueWordsUseCase(
+    private val repository: IWordRepository,
+    private val clock: Clock = Clock.System,
+) : FlowUseCase<Unit, List<Word>> {
+    override operator fun invoke(params: Unit): Flow<List<Word>> =
+        repository.observeWords().map { words ->
+            val today = clock.todayIn(TimeZone.currentSystemDefault())
+            words.filter { it.isDue(today) }.sortedBy { it.nextReviewDate }
+        }
+}
+
+// domain/usecase/ReviewWordUseCase.kt
+class ReviewWordUseCase(
+    private val repository: IWordRepository,
+    private val srsService: SpacedRepetitionService,
+) : UseCase<ReviewWordUseCase.Params, Word> {
+    data class Params(val word: Word, val quality: Int)
+
+    override suspend operator fun invoke(params: Params): Try<Word> {
+        val nextDate = srsService.calculateNextReview(params.word, params.quality)
+        val updated  = params.word.copy(
+            bucket         = params.word.bucket + if (params.quality >= 3) 1 else 0,
+            nextReviewDate = nextDate,
+        )
+        return repository.save(updated)
+    }
+}
+```
+
+### Domain Services
+
+For complex business logic involving multiple models or repositories:
+
+```kotlin
+// domain/service/SpacedRepetitionService.kt
+// Inject Clock so calculateNextReview() is deterministic in tests
+class SpacedRepetitionService(
+    private val clock: Clock = Clock.System,
+) {
+    fun calculateNextReview(word: Word, quality: Int): LocalDate {
+        val today = clock.todayIn(TimeZone.currentSystemDefault())
+        val interval = when {
+            quality < 2      -> 1
+            word.bucket == 0 -> 1
+            word.bucket == 1 -> 3
+            else             -> (word.bucket * 2.5).roundToInt()
+        }
+        return today.plus(interval, DateTimeUnit.DAY)
+    }
+}
+```
+
+---
+
+## Data Layer
+
+### Data Source Interfaces — live in `data`, not `domain`
+
+Data sources are implementation details of the data layer. Their interfaces use data-layer types (`WordEntity`, `WordDto`) and must **not** be placed in `domain`.
+
+```kotlin
+// data/datasource/IWordLocalDataSource.kt
+interface IWordLocalDataSource {
+    fun observeAll(): Flow<List<WordEntity>>
+    suspend fun findById(id: Int): WordEntity?
+    suspend fun upsert(entity: WordEntity)
+    suspend fun replaceAll(entities: List<WordEntity>)
+    suspend fun deleteById(id: Int)
+}
+
+// data/datasource/IWordRemoteDataSource.kt
+interface IWordRemoteDataSource {
+    suspend fun fetchAll(): Try<List<WordDto>>
+}
+
+// data/datasource/WordLocalDataSourceImpl.kt
+class WordLocalDataSourceImpl(
+    private val dao: WordDao,
+) : IWordLocalDataSource {
+    override fun observeAll(): Flow<List<WordEntity>> = dao.observeAll()
+    override suspend fun findById(id: Int): WordEntity? = dao.findById(id)
+    override suspend fun upsert(entity: WordEntity) = dao.upsert(entity)
+    override suspend fun replaceAll(entities: List<WordEntity>) = dao.replaceAll(entities)
+    override suspend fun deleteById(id: Int) = dao.deleteById(id)
+}
+```
+
+### Repository Implementation
+
+```kotlin
+// data/repository/WordRepositoryImpl.kt
+class WordRepositoryImpl(
+    private val local: IWordLocalDataSource,
+    private val remote: IWordRemoteDataSource,
+    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
+) : IWordRepository {
+
+    override fun observeWords(): Flow<List<Word>> =
+        local.observeAll().map { entities -> entities.map { it.toDomain() } }
+
+    override suspend fun findById(id: Int): Try<Word> = tryOf {
+        local.findById(id)?.toDomain() ?: error("Word $id not found")
+    }
+
+    override suspend fun save(word: Word): Try<Word> = tryOf {
+        local.upsert(word.toEntity())
+        word
+    }
+
+    override suspend fun syncWithRemote(): Try<Unit> = withContext(ioDispatcher) {
+        tryOf {
+            val dtos = remote.fetchAll().getOrThrow()
+            local.replaceAll(dtos.map { it.toEntity() })
+        }
+    }
+}
+```
+
+### Mappers — Extension Functions
+
+```kotlin
+// data/mapper/WordMapper.kt
+
+// Entity → Domain
+fun WordEntity.toDomain(): Word = Word(
+    id             = id,
+    original       = originalWord,
+    translated     = translatedWord,
+    bucket         = srsLevel,
+    nextReviewDate = LocalDate.parse(nextReview),
+    createdAt      = Instant.parse(createdAt),
+)
+
+// Domain → Entity
+fun Word.toEntity(): WordEntity = WordEntity(
+    id             = id,
+    originalWord   = original,
+    translatedWord = translated,
+    srsLevel       = bucket,
+    nextReview     = nextReviewDate.toString(),
+    createdAt      = createdAt.toString(),
+)
+
+// DTO → Entity (remote → local storage; no domain model involved)
+fun WordDto.toEntity(): WordEntity = WordEntity(
+    id             = id,
+    originalWord   = original,
+    translatedWord = translated,
+    srsLevel       = bucket ?: 0,
+    nextReview     = nextReviewDate ?: Clock.System.todayIn(TimeZone.currentSystemDefault()).toString(),
+    createdAt      = createdAt ?: Clock.System.now().toString(),
+)
+```
+
+Rules:
+- Always extension functions on the **source** type: `Entity.toDomain()`, `Domain.toEntity()`, `Dto.toEntity()`
+- No business logic in mappers — pure structural transformation
+- Mappers live in `data` — domain models never import data types
+
+---
+
+## Presentation Layer
+
+### State and Effects
+
+```kotlin
+// feature/words/WordListContract.kt
+@Immutable
+data class WordListState(
+    val words: ImmutableList<Word> = persistentListOf(),
+    val isLoading: Boolean = true,
+    val error: String? = null,
+)
+
+sealed interface WordListEffect {
+    data class ShowUndo(val word: Word) : WordListEffect
+    data class ShowError(val message: String) : WordListEffect
+}
+```
+
+### ViewModel
+
+```kotlin
+// feature/words/WordListViewModel.kt
+class WordListViewModel(
+    private val getWords: GetDueWordsUseCase,
+    private val deleteWord: DeleteWordUseCase,
+) : BaseViewModel<WordListState, WordListEffect>() {
+
+    override fun initialState() = WordListState()
+
+    init { observeWords() }
+
+    private fun observeWords() {
+        viewModelScope.launch {
+            getWords(Unit).collect { words ->
+                updateState { copy(words = words.toImmutableList(), isLoading = false) }
+            }
+        }
+    }
+
+    fun deleteWord(word: Word) {
+        viewModelScope.launch {
+            deleteWord(word.id).reduce(
+                onSuccess = { emitEffect(WordListEffect.ShowUndo(word)) },
+                onFailure = { e -> emitEffect(WordListEffect.ShowError(e.message ?: "Unknown error")) },
+            )
+        }
+    }
+}
+```
+
+### Screen Composable
+
+```kotlin
+// feature/words/WordListScreen.kt
+@Composable
+fun WordListScreen(
+    viewModel: WordListViewModel = koinViewModel(),
+) {
+    val state by viewModel.collectState()
+    val snackbarHostState = remember { SnackbarHostState() }
+
+    // One-shot effects via OnEvents — never LaunchedEffect for navigation/effects
+    OnEvents(viewModel.effects) { effect ->
+        when (effect) {
+            is WordListEffect.ShowUndo  -> snackbarHostState.showSnackbar("Deleted")
+            is WordListEffect.ShowError -> snackbarHostState.showSnackbar(effect.message)
+        }
+    }
+
+    WordListContent(
+        state = state,
+        onDeleteWord = viewModel::deleteWord,
+    )
+}
+
+// Stateless — only receives state and lambdas, no ViewModel reference
+@Composable
+private fun WordListContent(
+    state: WordListState,
+    onDeleteWord: (Word) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    // pure rendering
+}
+```
+
+Rules:
+- `viewModel.collectState()` — never `collectAsStateWithLifecycle()`
+- `OnEvents` for one-shot effects — never `LaunchedEffect` for navigation/side effects
+- Only the root screen composable knows about the ViewModel; pass lambdas and state down
+
+### Data Flow
+
+```
+Screen
+  ↓  observes state via viewModel.collectState()
+  ↓  calls event sink methods (plain ViewModel functions)
+ViewModel
+  ↓  invokes use cases
+  ↓  updateState {} / emitEffect {}
+Use Case
+  ↓  calls repository interface methods
+  ↓  applies domain business logic
+Repository Impl
+  ↓  coordinates local + remote data sources
+DataSource Impls → Room / Ktor / SQLDelight / Firebase
+```
+
+---
+
+## Module Boundaries (Gradle)
+
+```
+:app
+  → :feature:words  :feature:study  :feature:auth  :feature:profile
+      → :domain  :core:*  :resources
+      ↛ :feature:*  (features NEVER depend on each other)
+:core:design-system → Compose only  (no :domain, no :core:network)
+:core:testing → commonTest only     (never on production classpath)
+```
+
+Each `:feature:X` must:
+- Expose a `NavGraphBuilder` extension — wired in `:app`
+- Expose a Koin module — wired in `:app`
+- Use convention plugins — no duplicated Gradle boilerplate
+
+### Forbidden Imports
+
+| Module | Must NOT import |
+|---|---|
+| `:domain` | Room, Ktor, Koin, Hilt, Compose, Android SDK, any framework |
+| `:feature:*` | Any other `:feature:*` module |
+| `:core:design-system` | `:domain`, `:data`, `:feature:*` |
+| `:core:common` | `:feature:*`, `:app` |
+| `:core:testing` | Any production module on production classpath |
+
+---
+
+## Error Propagation
+
+```
+DataSource    → throws (framework exceptions bubble up naturally)
+Repository    → wraps in tryOf {}, never throws outward
+Use Case      → passes through / transforms Try<T>
+ViewModel     → .reduce(onSuccess, onFailure) → updateState / emitEffect
+Screen        → renders error state or shows snackbar via OnEvents
+```
+
+No exceptions should escape the repository layer unhandled.
+
+---
+
+## Testing Each Layer
+
+### Fake Repository (fakes over mocks)
+
+```kotlin
+// core/testing/fake/FakeWordRepository.kt
+class FakeWordRepository : IWordRepository {
+    val words = mutableListOf<Word>()
+    var shouldFail = false
+
+    override fun observeWords(): Flow<List<Word>> = flowOf(words.toList())
+
+    override suspend fun findById(id: Int): Try<Word> =
+        if (shouldFail) Try.Failure(Exception("forced failure"))
+        else words.firstOrNull { it.id == id }
+            ?.let { Try.Success(it) }
+            ?: Try.Failure(Exception("Word $id not found"))
+
+    override suspend fun save(word: Word): Try<Word> = tryOf {
+        words.removeAll { it.id == word.id }
+        words.add(word)
+        word
+    }
+
+    override suspend fun delete(id: Int): Try<Unit> = tryOf {
+        words.removeAll { it.id == id }
+    }
+
+    override suspend fun syncWithRemote(): Try<Unit> = Try.Success(Unit)
+}
+```
+
+### ViewModel Test (Turbine)
+
+```kotlin
+class WordListViewModelTest {
+    private val repository = FakeWordRepository()
+    private val fakeClock = FakeClock(today = LocalDate(2025, 1, 10))
+    private val getWords = GetDueWordsUseCase(repository, fakeClock)
+    private val deleteWord = DeleteWordUseCase(repository)
+    private val viewModel by lazy { WordListViewModel(getWords, deleteWord) }
+
+    @Test
+    fun `loads due words on init`() = runTest {
+        val dueWord = wordFixture(nextReviewDate = LocalDate(2025, 1, 9))  // before today
+        repository.words.add(dueWord)
+
+        viewModel.state.test {
+            val state = awaitItem()
+            assertEquals(listOf(dueWord), state.words.toList())
+            assertFalse(state.isLoading)
+        }
+    }
+
+    @Test
+    fun `delete word emits ShowUndo effect`() = runTest {
+        val word = wordFixture()
+        repository.words.add(word)
+
+        viewModel.effects.test {
+            viewModel.deleteWord(word)
+            assertEquals(WordListEffect.ShowUndo(word), awaitItem())
+        }
+    }
+
+    @Test
+    fun `delete failure emits ShowError effect`() = runTest {
+        val word = wordFixture()
+        repository.words.add(word)
+        repository.shouldFail = true
+
+        viewModel.effects.test {
+            viewModel.deleteWord(word)
+            assertIs<WordListEffect.ShowError>(awaitItem())
+        }
+    }
+}
+```
+
+### Layer Test Summary
+
+| Layer | Test approach | Tools |
+|---|---|---|
+| Domain / Use Case | Unit — pure logic, inject `FakeClock` | `kotlin-test`, fakes |
+| Repository | Unit — fake data sources | `FakeWordLocalDataSource` |
+| Data Source | Unit — fake HTTP / in-memory DB | `MockEngine`, in-memory Room |
+| ViewModel | Unit — state & effects | Turbine, fake use cases |
+| Integration | Instrumented | Hilt testing, real DB |
+| UI | Compose UI testing | `composeTestRule` |

+ 376 - 0
.claude/skills/clean-code/SKILL.md

@@ -0,0 +1,376 @@
+---
+name: clean-code
+description: Enforce clean Kotlin: naming conventions, null safety (no !!), value classes, scope functions, sealed interfaces, expression bodies, immutability, and single-responsibility — applied during code review or when writing new Kotlin for Android/KMP
+argument-hint: "<code area or description>"
+user-invocable: true
+allowed-tools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"]
+---
+
+# Clean Code — Kotlin / Android / KMP
+
+## Project Convention: `Try<T>`
+
+`Try<T>` is this project's typed error wrapper (Arrow's `Either<AppError, T>`).
+- `Success(value)` — operation succeeded
+- `Failure(error: AppError)` — operation failed, never throws
+- Use `.fold {}`, `.map {}`, `.getOrElse {}` — never unwrap with `!!`
+
+```kotlin
+typealias Try<T> = Either<AppError, T>
+
+// Usage
+suspend fun findWord(id: WordId): Try<Word>  // returns Failure if not found
+```
+
+---
+
+## Naming
+
+| Construct | Convention | Example |
+|---|---|---|
+| Class, Interface, Object, Enum | `PascalCase` | `UserRepository` |
+| Function, property, variable | `camelCase` | `fetchUserProfile()` |
+| `const val`, top-level immutable | `SCREAMING_SNAKE_CASE` | `MAX_RETRY_COUNT` |
+| Package | lowercase, no underscores | `com.example.feature.auth` |
+| `@Composable` function | `PascalCase` (they are types) | `WordCard()` |
+| Enum entries | `SCREAMING_SNAKE_CASE` | `ReviewQuality.HARD` |
+
+### Meaningful Names
+- Name reveals intent: `dueWords` not `list`, `isExpired` not `flag`
+- No abbreviations: `userRepository` not `usrRepo`
+- Booleans: `isLoading`, `hasError`, `canSubmit`
+- Functions are verbs: `loadWords()`, `deleteUser()`, `calculateScore()`
+- Avoid redundant context: inside `UserRepository`, use `findById()` not `findUserById()`
+
+```kotlin
+// Good
+val dueWords = words.filter { it.isDue() }
+fun calculateNextReviewDate(word: Word, quality: Int): LocalDate
+
+// Bad
+val list2 = wrds.filter { it.d }
+fun calc(w: Word, q: Int): LocalDate
+```
+
+### Test Naming
+Test names communicate intent — use backtick names in JUnit5:
+
+```kotlin
+@Test fun `given expired card, when charging, then returns Failure`()
+@Test fun `calculateScore returns zero when no words reviewed`()
+@Test fun `isDue returns false when reviewed within the last hour`()
+```
+
+---
+
+## Functions
+
+### One Responsibility
+If you need "and" to describe a function, split it.
+
+```kotlin
+// Bad — validates AND charges AND logs AND notifies
+fun processPayment(card: Card, amount: Int) { ... }
+
+// Good — composed from named responsibilities
+fun processPayment(card: Card, amount: Int): Try<Receipt> = tryOf {
+    validate(card)
+    val receipt = charge(card, amount)
+    logPayment(receipt)
+    notifyUser(receipt)
+    receipt
+}
+```
+
+### Expression Bodies
+Prefer expression bodies for single-expression functions — removes ceremony.
+
+```kotlin
+// Bad
+fun isDue(): Boolean {
+    return nextReviewDate <= LocalDate.now()
+}
+
+// Good
+fun isDue(): Boolean = nextReviewDate <= LocalDate.now()
+fun dueCount(): Int = words.count { it.isDue() }
+fun label(bucket: Int): String = when (bucket) {
+    0       -> "New"
+    in 1..2 -> "Learning"
+    else    -> "Mastered"
+}
+```
+
+### Parameter Count
+- Prefer fewer than 3 parameters. More than 3: use a data class.
+- Named arguments for multi-param calls.
+- Default parameters over overloads.
+
+```kotlin
+// Bad
+fun createWord(original: String, translated: String, notes: String, tags: List<String>, difficulty: Int)
+
+// Good
+data class CreateWordParams(
+    val original: String,
+    val translated: String,
+    val notes: String = "",
+    val tags: List<String> = emptyList(),
+    val difficulty: Int = 1,
+)
+fun createWord(params: CreateWordParams): Try<Word>
+```
+
+---
+
+## Immutability
+
+Prefer `val` over `var`. Prefer immutable collections. Mutate by replacing, not by modifying in place.
+
+```kotlin
+// Bad
+var words = mutableListOf<Word>()
+words.add(newWord)
+
+// Good
+val words: List<Word> = emptyList()
+val updated = words + newWord   // replace, don't mutate
+
+// Bad — mutable data class property
+data class User(var name: String)
+
+// Good — immutable + copy()
+data class User(val name: String)
+val renamed = user.copy(name = "Ali")
+```
+
+---
+
+## Value Classes — Eliminate Primitive Obsession
+
+Use `@JvmInline value class` to make IDs and domain primitives type-safe at compile time.
+
+```kotlin
+// Bad — silent bug: arguments in wrong order, compiler won't catch it
+fun createUser(id: Int, tenantId: Int, age: Int)
+
+// Good — compiler enforces correct types
+@JvmInline value class UserId(val value: Int)
+@JvmInline value class TenantId(val value: Int)
+@JvmInline value class Age(val value: Int)
+
+fun createUser(id: UserId, tenantId: TenantId, age: Age)
+```
+
+---
+
+## Null Safety
+
+Design APIs to return non-null types. Use sealed types or `Try<T>` for absence/failure.
+
+```kotlin
+// Bad — caller must always check null
+suspend fun findWord(id: WordId): Word?
+
+// Good — absence represented explicitly
+suspend fun findWord(id: WordId): Try<Word>   // Failure = not found
+// or
+sealed interface FindResult {
+    data class Found(val word: Word) : FindResult
+    data object NotFound : FindResult
+}
+```
+
+### Safe Navigation Rules
+- `?.` and `?:` over `!!`
+- `!!` only when non-null is guaranteed by invariant — add a comment explaining why
+- `requireNotNull(value) { "reason" }` for intentional assertions at system boundaries
+
+```kotlin
+// Good
+val name = user?.profile?.displayName ?: "Anonymous"
+
+// Acceptable — with justification
+val view = binding.root  // binding is always non-null after onCreateView
+
+// Never
+val name = user!!.profile!!.displayName
+```
+
+---
+
+## Kotlin Idioms
+
+### Scope Functions
+
+| Function | Object ref | Returns | Use for |
+|---|---|---|---|
+| `let` | `it` | lambda result | Null-safe transform, scoped variable |
+| `run` | `this` | lambda result | Configuration + compute result |
+| `with` | `this` | lambda result | Group operations on a non-null receiver |
+| `apply` | `this` | receiver | Builder-style initialisation |
+| `also` | `it` | receiver | Side effects (logging, validation) |
+
+```kotlin
+// let — safe-call chain + transform
+user?.let { sendWelcomeEmail(it.email) }
+
+// apply — builder-style setup
+val intent = Intent(context, MainActivity::class.java).apply {
+    putExtra("userId", userId)
+    flags = Intent.FLAG_ACTIVITY_NEW_TASK
+}
+
+// also — side effect without breaking chain
+fetchWords()
+    .also { logger.log("Fetched ${it.size} words") }
+    .map { it.toDomain() }
+```
+
+> Avoid `runCatching {}` — use `Try<T>` / `Either` so errors are typed and explicit.
+
+### Sealed Interfaces (prefer over sealed classes)
+
+```kotlin
+sealed interface AuthResult {
+    data class Success(val user: User) : AuthResult
+    data class Failure(val reason: AuthError) : AuthResult
+    data object Cancelled : AuthResult
+}
+
+// Exhaustive — no else needed
+when (result) {
+    is AuthResult.Success   -> onSuccess(result.user)
+    is AuthResult.Failure   -> showError(result.reason)
+    AuthResult.Cancelled    -> onCancelled()
+}
+```
+
+### Extension Functions
+
+Extend existing types with domain behaviour without inheritance.
+
+```kotlin
+// Domain extensions
+fun Word.isDue(): Boolean = nextReviewDate <= LocalDate.now()
+fun List<Word>.dueCount(): Int = count { it.isDue() }
+
+// Formatting
+fun LocalDate.toDisplayString(): String =
+    format(DateTimeFormatter.ofPattern("MMM d, yyyy"))
+```
+
+### `companion object` — Factory Methods and Constants
+
+```kotlin
+class AuthToken private constructor(val value: String) {
+    companion object {
+        fun from(raw: String): Try<AuthToken> =
+            if (raw.isBlank()) Failure(AuthError.InvalidToken) else Success(AuthToken(raw))
+        val Empty = AuthToken("")
+    }
+}
+```
+
+### `typealias` for Readability
+
+```kotlin
+typealias WordId = Int              // signals intent at call sites
+typealias ReviewHistory = List<ReviewEntry>
+typealias OnWordSelected = (Word) -> Unit
+```
+
+### When Expressions
+
+Prefer `when` over long if-else chains.
+
+```kotlin
+val label = when (bucket) {
+    0         -> "New"
+    in 1..2   -> "Learning"
+    in 3..4   -> "Familiar"
+    else      -> "Mastered"
+}
+
+when {
+    isLoading       -> showLoading()
+    hasError        -> showError()
+    items.isEmpty() -> showEmpty()
+    else            -> showContent()
+}
+```
+
+---
+
+## Constants & Magic Values
+
+No magic numbers or strings in business logic.
+
+```kotlin
+object ReviewQuality {
+    const val AGAIN = 0
+    const val HARD  = 2
+    const val GOOD  = 4
+    const val EASY  = 5
+}
+
+object Timeouts {
+    val networkRequest = 30.seconds
+    val cacheExpiry    = 24.hours
+}
+```
+
+---
+
+## Comments
+
+- Comments explain **why**, not **what** — code explains itself.
+- Delete commented-out code; version control remembers it.
+- KDoc on public API in shared modules.
+
+```kotlin
+// Bad — the what is obvious
+// Filter words by due date
+val due = words.filter { it.isDue() }
+
+// Good — explains the why (SRS algorithm detail)
+// Words reviewed within the last hour are excluded to prevent over-drilling
+val due = words.filter { it.isDue() && it.lastReviewed.isBefore(oneHourAgo) }
+```
+
+---
+
+## Anti-Patterns
+
+| Anti-Pattern | Prefer Instead |
+|---|---|
+| `!!` | Safe calls, Elvis, `requireNotNull` |
+| `try-catch` for control flow | `Try<T>`, `.fold {}` |
+| `var` in data classes | `val` + `copy()` |
+| Long parameter lists | Data class params |
+| Deeply nested lambdas | Coroutines / named functions |
+| `Any` / unchecked casts | Generics, sealed types |
+| Empty `catch {}` | Always handle or rethrow |
+| Commented-out code | Delete it |
+| `runCatching {}` | `Try<T>` / `Either` — typed errors |
+| Primitive IDs (`Int`, `String`) | `@JvmInline value class` |
+| `LiveData` in new code | `StateFlow` / `SharedFlow` |
+| Mocks in tests | Fakes (`FakeXxxRepository`) |
+
+---
+
+## Code Review Checklist
+
+Run through this when reviewing or writing Kotlin:
+
+- [ ] No `!!` without a justification comment
+- [ ] No `var` in data classes — use `val` + `copy()`
+- [ ] No magic literals — use named constants or `value class`
+- [ ] Functions under ~20 lines, single responsibility
+- [ ] All failure paths return `Try<T>` or a sealed type — no `null` leaking out
+- [ ] No `try-catch` for control flow
+- [ ] Booleans named `is*`, `has*`, or `can*`
+- [ ] IDs are `value class`, not raw `Int`/`String`
+- [ ] No commented-out code
+- [ ] Test names describe the scenario: `given_when_then` or `should_X_when_Y`
+- [ ] KDoc present on all public API in shared modules

+ 325 - 0
.claude/skills/compose-skill/scripts/validate.sh

@@ -0,0 +1,325 @@
+---
+name: compose-stability
+description: Diagnose and fix Compose stability — @Stable, @Immutable, ImmutableList, strong skipping mode, compiler metrics, and lambda stability for zero-waste recomposition
+argument-hint: "<composable, data class, or state class to analyse>"
+user-invocable: true
+allowed-tools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"]
+---
+
+# Compose Stability
+
+Compose skips recomposition of a composable when all its inputs are **stable** and **equal** to their previous values. An unstable parameter forces full recomposition of that subtree every time the parent recomposes.
+
+## Stability Contract
+
+A type is **stable** if:
+1. `equals()` is always consistent for the same data
+2. Public properties are read-only (`val`) or observable via Compose snapshot state
+3. All public property types are also stable
+
+**Stable by default:**
+- Primitives: `Boolean`, `Int`, `Long`, `Float`, `Double`, `Char`
+- `String`
+- Lambda types: `() -> Unit`, `(T) -> R`
+- Compose `State<T>` and `MutableState<T>`
+- Types annotated `@Stable` or `@Immutable`
+
+**Unstable by default:**
+- `List<T>`, `Map<K,V>`, `Set<T>` — mutable implementations possible at runtime
+- Any class with `var` properties
+- **Interfaces** — always unstable regardless of implementation
+- Classes from external modules the Compose compiler cannot inspect
+- Classes from non-Kotlin modules (Java classes)
+
+---
+
+## @Immutable vs @Stable
+
+### When are annotations actually needed?
+
+The Compose compiler **infers stability automatically** for classes it can inspect. You only need explicit annotations when:
+- The class is in a **module without the Compose compiler plugin** (the compiler can't see inside it)
+- The class holds `var` properties backed by snapshot state
+- You want to assert stability for an **interface** type
+
+If a `data class` with only stable `val` fields is in the same Compose module, the compiler will mark it stable without any annotation.
+
+### @Immutable — Deep Immutability Promise
+
+Use when **all** properties are `val` and all property types are themselves immutable:
+
+```kotlin
+@Immutable
+data class ThemeColors(
+    val primary: Color,
+    val secondary: Color,
+    val background: Color,
+)
+
+@Immutable
+data class UserProfile(
+    val id: String,
+    val name: String,
+    val avatarUrl: String,
+)
+```
+
+### @Stable — Stability Contract Promise
+
+Use when the type is in a non-Compose module, or holds mutable snapshot state:
+
+```kotlin
+// Needed: class lives in a :domain module without Compose compiler
+@Stable
+data class WordListState(
+    val words: ImmutableList<Word>,
+    val isLoading: Boolean,
+    val error: String?,
+)
+
+// Needed: holds MutableState
+@Stable
+class ThemeManager(
+    val current: MutableState<Theme> = mutableStateOf(Theme.Light)
+)
+```
+
+**Warning:** `@Stable` and `@Immutable` are promises to the Compose compiler. Breaking them causes silent bugs — recompositions skip when they should run.
+
+---
+
+## Fix: Unstable Collections
+
+The most common stability issue — `List<T>` is considered unstable.
+
+```toml
+# gradle/libs.versions.toml
+kotlinx-collections-immutable = "0.3.8"
+```
+
+```kotlin
+// build.gradle.kts
+implementation(libs.kotlinx.collections.immutable)
+```
+
+```kotlin
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.toImmutableList
+
+// Before — unstable, forces recomposition on every parent recompose
+data class WordState(
+    val words: List<Word>,          // UNSTABLE
+    val tags: Map<String, Int>,     // UNSTABLE
+)
+
+// After — compiler infers stable (no annotation needed if in a Compose module)
+data class WordState(
+    val words: ImmutableList<Word>       = persistentListOf(),   // STABLE
+    val tags: ImmutableMap<String, Int>  = persistentMapOf(),    // STABLE
+)
+
+// ViewModel — convert before updating state
+updateState { copy(words = newWords.toImmutableList()) }
+```
+
+---
+
+## Fix: Unstable Lambdas
+
+Lambdas that capture changing variables are unstable. Use function references or `remember`:
+
+```kotlin
+// Problem — new lambda created on every recomposition when word changes
+WordCard(
+    word    = word,
+    onClick = { viewModel.select(word) },   // captures word — new lambda each recompose
+)
+
+// Solution 1 — function reference (stable when viewModel is stable)
+WordCard(
+    word    = word,
+    onClick = viewModel::select,            // works if select(word: Word)
+)
+
+// Solution 2 — remember with key
+val onClick = remember(word.id) { { viewModel.select(word) } }
+WordCard(word = word, onClick = onClick)
+
+// Solution 3 — pass ID instead of full object
+WordCard(
+    wordId  = word.id,
+    onClick = viewModel::selectById,        // stable function reference
+)
+```
+
+---
+
+## Fix: Interfaces Are Always Unstable
+
+Interfaces have no concrete type the compiler can inspect, so any composable that takes an interface parameter cannot be skipped.
+
+```kotlin
+// Problem — interface param forces recomposition even if impl is @Stable
+interface WordClickHandler { fun onClick(word: Word) }
+
+@Composable
+fun WordCard(handler: WordClickHandler) { … }   // UNSTABLE: interface
+
+// Fix 1 — replace interface param with lambda
+@Composable
+fun WordCard(onClick: (Word) -> Unit) { … }     // lambdas are stable
+
+// Fix 2 — use a @Stable concrete class
+@Stable
+class WordClickHandlerImpl(private val vm: WordViewModel) : WordClickHandler {
+    override fun onClick(word: Word) = vm.select(word)
+}
+
+// Fix 3 — enable strong skipping (reference equality check handles it)
+```
+
+---
+
+## Fix: Multi-Module Stability (KMP)
+
+Types defined in modules **without the Compose compiler plugin** (e.g., a `:domain` module) are opaque — the compiler cannot verify their properties.
+
+```
+:domain          ← pure Kotlin module, no Compose plugin → types appear unstable
+:feature:words   ← Compose module, uses domain types
+```
+
+Three options:
+
+```kotlin
+// Option A — annotate at definition site if you own the module
+// In :domain module, add @Immutable/@Stable to expose the contract
+@Immutable
+data class Word(val id: String, val text: String)
+
+// Option B — map to a UI model in the presentation layer (preferred for clean architecture)
+@Immutable
+data class WordUiModel(val id: String, val text: String)  // stable, Compose-owned type
+
+// Option C — stability-config.conf for types you don't own (see section below)
+```
+
+---
+
+## Strong Skipping Mode
+
+Enables skipping for composables with unstable parameters when all parameter instances are **reference-equal**. Also automatically remembers lambdas.
+
+```kotlin
+// Kotlin < 2.0 — opt in explicitly
+composeCompiler {
+    enableStrongSkippingMode = true
+}
+
+// Kotlin 2.0+ — strong skipping is ON by default; no flag needed
+// (Compose Compiler 1.5.8+ bundled with Kotlin 2.0)
+```
+
+With strong skipping:
+- All composable functions become restartable
+- Unstable parameters compared by reference equality — if the same instance, skip
+- Lambdas are automatically remembered — no new lambda allocation per recompose
+
+---
+
+## `@NonRestartableComposable` for Leaf Nodes
+
+Pure leaf composables that read no state and have no child composables can skip the restart machinery entirely:
+
+```kotlin
+@NonRestartableComposable
+@Composable
+fun WordChip(text: String, modifier: Modifier = Modifier) {
+    // No state reads — parent recompose will re-invoke directly; no independent restart needed
+    Text(text = text, modifier = modifier)
+}
+```
+
+Use only for true leaves — nodes that don't call other restartable composables.
+
+---
+
+## Compose Compiler Metrics & Reports
+
+Enable to find stability issues without guessing:
+
+```kotlin
+// build.gradle.kts
+composeCompiler {
+    reportsDestination  = layout.buildDirectory.dir("compose_compiler")
+    metricsDestination  = layout.buildDirectory.dir("compose_compiler")
+}
+```
+
+```bash
+./gradlew :composeApp:assembleDebug
+# Reports in: build/compose_compiler/
+```
+
+**What to look for in `*-composables.txt`:**
+
+```
+restartable skippable fun WordCard(        ← ideal: skippable
+  stable word: Word
+  stable onClick: Function0<Unit>
+)
+restartable fun WordListScreen(            ← cannot skip — investigate params
+  unstable state: WordListState            ← this is the culprit
+)
+```
+
+**What to look for in `*-classes.txt`:**
+
+```
+stable   WordListState                   ← good
+unstable WordListState                   ← fix the fields, not just the annotation
+  unstable words: List<Word>             ← replace with ImmutableList
+```
+
+---
+
+## Stability for External / Java Classes
+
+For classes you don't own (e.g., from third-party libraries):
+
+```kotlin
+// stability-config.conf (in module root)
+// Tell Compose compiler these types are stable
+com.google.firebase.auth.FirebaseUser
+java.time.LocalDate
+java.util.UUID
+```
+
+```kotlin
+// build.gradle.kts
+composeCompiler {
+    stabilityConfigurationFiles.add(
+        rootProject.layout.projectDirectory.file("stability-config.conf")
+    )
+}
+```
+
+---
+
+## Stability Diagnostic Checklist
+
+1. Enable compiler reports in `build.gradle.kts`
+2. Run `./gradlew :composeApp:assembleDebug`
+3. Search `*-composables.txt` for `restartable` without `skippable` — investigate each
+4. Search `*-classes.txt` for `unstable` — fix the **fields**, not just the annotation
+5. Replace `List<T>` → `ImmutableList<T>` in all state/UI data classes
+6. Replace `Map<K,V>` → `ImmutableMap<K,V>` in all state/UI data classes
+7. Map domain types to `@Immutable` UI models in the presentation layer
+8. Replace interface params with lambdas or `@Stable` concrete classes
+9. Annotate classes in non-Compose modules with `@Stable`/`@Immutable` (or use stability-config.conf for third-party)
+10. Enable strong skipping mode (Kotlin < 2.0 only)
+11. Mark true leaf composables with `@NonRestartableComposable`
+12. Verify fixes in Layout Inspector → Recomposition counts (Live Updates mode)

+ 474 - 0
.claude/skills/coroutines-flow/SKILL.md

@@ -0,0 +1,474 @@
+---
+name: coroutines-flow
+description: Write safe Kotlin Coroutines and Flow code — structured concurrency, Flow operators, StateFlow, SharedFlow, error handling, cancellation, and testing with Turbine
+argument-hint: "<async operation or stream to implement>"
+user-invocable: true
+allowed-tools: ["Read", "Write", "Edit", "Glob", "Grep"]
+---
+
+# Kotlin Coroutines & Flow
+
+## Structured Concurrency
+
+Always launch coroutines in the correct scope. The scope defines lifetime — if the scope is cancelled, all child coroutines are cancelled.
+
+```kotlin
+// ViewModel — viewModelScope cancelled when VM is cleared
+class WordViewModel : ViewModel() {
+    fun load() {
+        viewModelScope.launch {
+            // cancelled when ViewModel is destroyed
+        }
+    }
+}
+
+// Fragment/Activity — lifecycleScope cancelled on destroy
+lifecycleScope.launch {
+    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+        viewModel.uiState.collect { ... }
+    }
+}
+
+// Repository — use coroutineScope / supervisorScope for parallel ops
+suspend fun syncAll(): Try<Unit> = tryOf {
+    coroutineScope {
+        val words  = async { remote.fetchWords() }
+        val tags   = async { remote.fetchTags() }
+        local.save(words.await().getOrThrow(), tags.await().getOrThrow())
+    }
+}
+```
+
+### supervisorScope vs coroutineScope
+
+```kotlin
+// coroutineScope — one child failure cancels ALL siblings
+coroutineScope {
+    launch { riskyA() }   // fails → riskyB() is also cancelled
+    launch { riskyB() }
+}
+
+// supervisorScope — children are independent; one failure doesn't cancel siblings
+supervisorScope {
+    launch { tryA() }     // fails → tryB() continues unaffected
+    launch { tryB() }
+}
+```
+
+### Try<T> Integration
+
+Suspend repository functions return `Try<T>` — they never throw. Use `tryOf {}` to wrap any suspending work:
+
+```kotlin
+// Repository contract: suspend fun returns Try<T>, never throws
+suspend fun fetchWords(): Try<List<Word>> = tryOf {
+    withContext(Dispatchers.IO) { remote.getWords() }
+}
+
+// Caller unwraps safely
+suspend fun refresh() {
+    fetchWords()
+        .onSuccess { words -> local.save(words) }
+        .onFailure { e -> logError(e) }
+}
+```
+
+---
+
+## Dispatchers
+
+```kotlin
+Dispatchers.Main           // UI thread — Compose state updates, navigation
+Dispatchers.Main.immediate // UI thread — no coroutine overhead if already on Main
+Dispatchers.IO             // network, file I/O, database
+Dispatchers.Default        // CPU-intensive work (sorting, parsing)
+Dispatchers.Unconfined     // avoid — unpredictable thread
+```
+
+```kotlin
+// withContext — switch dispatcher for a block
+suspend fun fetchAndParse(url: String): List<Word> = withContext(Dispatchers.IO) {
+    val json = httpClient.get(url).bodyAsText()
+    withContext(Dispatchers.Default) {
+        Json.decodeFromString<List<WordDto>>(json).map { it.toDomain() }
+    }
+}
+```
+
+In ViewModels with `viewModelScope`, the default dispatcher is `Main`. Use `withContext(Dispatchers.IO)` for blocking work.
+
+---
+
+## Flow
+
+### Cold vs Hot
+
+| | Cold Flow | Hot Flow |
+|---|---|---|
+| **Starts** | On each collector | Regardless of collectors |
+| **Examples** | `flow {}`, `callbackFlow` | `StateFlow`, `SharedFlow` |
+| **Multiple collectors** | Independent executions | Share the same upstream |
+
+### flow {} Builder
+
+```kotlin
+fun observeNetworkStatus(): Flow<Boolean> = flow {
+    while (true) {
+        ensureActive()           // respect cancellation in tight loops
+        emit(checkConnectivity())
+        delay(5_000)
+    }
+}.flowOn(Dispatchers.IO)        // upstream runs on IO, downstream on caller's context
+```
+
+### callbackFlow — Wrap Callback APIs
+
+```kotlin
+fun observeLocationUpdates(): Flow<Location> = callbackFlow {
+    val client = LocationServices.getFusedLocationProviderClient(context)
+    val callback = object : LocationCallback() {
+        override fun onLocationResult(result: LocationResult) {
+            result.lastLocation?.let { trySend(it) }
+        }
+    }
+    client.requestLocationUpdates(locationRequest, callback, Looper.getMainLooper())
+    awaitClose { client.removeLocationUpdates(callback) }
+}
+```
+
+### channelFlow — Emit from Multiple Coroutines
+
+Use `channelFlow` when you need to emit from multiple concurrent coroutines (e.g., merge a local cache with a network poll):
+
+```kotlin
+fun observeWithRefresh(): Flow<List<Word>> = channelFlow {
+    launch { dao.observeAll().collect { send(it.map(WordEntity::toDomain)) } }
+    launch {
+        while (true) {
+            delay(60_000)
+            remote.fetchWords().onSuccess { send(it) }
+        }
+    }
+}.flowOn(Dispatchers.IO)
+```
+
+### Key Operators
+
+```kotlin
+// Transform
+flow.map { it.toDomain() }
+flow.mapNotNull { it?.toDomain() }
+flow.filter { it.isActive }
+flow.filterNotNull()
+
+// Side effects (don't emit, just observe)
+flow.onStart { emit(Loading) }           // runs before first emission
+flow.onEach { log(it) }                  // runs on each item without transforming
+flow.onCompletion { cause -> cleanup() } // runs when flow ends (normally or via error)
+
+// Flatten (for Flow of Flow)
+flow.flatMapLatest { id -> repository.observeById(id) }  // cancel previous on new emission
+flow.flatMapConcat { ... }  // sequential
+flow.flatMapMerge { ... }   // concurrent
+
+// Combine multiple flows
+combine(flowA, flowB) { a, b -> Pair(a, b) }
+zip(flowA, flowB) { a, b -> Pair(a, b) }   // waits for both
+merge(flowA, flowB)                         // merge emissions from both
+
+// Accumulate
+flow.scan(initial) { acc, item -> acc + item }  // emit running total after each item
+flow.runningFold(initial) { acc, item -> acc + item }  // alias for scan
+
+// Backpressure
+flow.buffer(capacity = 64)   // decouple producer and collector; producer runs ahead
+flow.conflate()               // skip intermediate values; collector always gets latest
+
+// Timing
+flow.debounce(300)           // wait 300ms of silence before emitting (search box)
+flow.sample(1000)            // emit latest every 1s
+flow.distinctUntilChanged()  // skip duplicate consecutive emissions
+
+// Collect via operator (alternative to launch { collect {} })
+flow
+    .onEach { render(it) }
+    .launchIn(lifecycleScope)  // returns Job; scope cancels collection
+
+// Terminal
+flow.first()                 // get first emission (suspending)
+flow.firstOrNull()
+flow.toList()                // collect all into list (suspending)
+flow.single()                // exactly one emission, else exception
+```
+
+### Error Handling
+
+```kotlin
+// catch {} — handle errors in upstream flow
+flow
+    .map { it.toDomain() }
+    .catch { e ->
+        emit(emptyList())      // emit fallback
+        // or: throw e         // rethrow transformed
+    }
+    .collect { ... }
+
+// retry
+flow
+    .retry(3) { e -> e is IOException }  // retry up to 3 times on IOException
+    .catch { e -> handleFinalFailure(e) }
+    .collect { ... }
+
+// retryWhen — exponential backoff
+flow.retryWhen { cause, attempt ->
+    if (cause is IOException && attempt < 3) {
+        delay(2.0.pow(attempt.toDouble()).toLong() * 1000)
+        true
+    } else false
+}
+```
+
+Never use `try-catch` wrapping `collect {}` for Flow error handling — use `.catch {}` operator instead.
+
+---
+
+## StateFlow — Hot, Single Value
+
+### Preferred: stateIn (cold → hot conversion)
+
+Convert a cold repository Flow into a hot `StateFlow` in the ViewModel using `stateIn`. This is the recommended pattern — it avoids a separate `MutableStateFlow` and wires lifecycle automatically.
+
+```kotlin
+val state: StateFlow<WordListState> = getWordsUseCase()
+    .map { words -> WordListState(words = words) }
+    .stateIn(
+        scope = viewModelScope,
+        started = SharingStarted.WhileSubscribed(5_000), // 5s grace on config change
+        initialValue = WordListState(),
+    )
+```
+
+`SharingStarted` options:
+- `WhileSubscribed(5_000)` — stops upstream 5s after last collector; **use for production ViewModels**
+- `Eagerly` — starts immediately, never stops
+- `Lazily` — starts on first collector, never stops
+
+### Manual MutableStateFlow
+
+When state is updated by ViewModel logic rather than derived from a flow:
+
+```kotlin
+private val _state = MutableStateFlow(WordListState())
+val state: StateFlow<WordListState> = _state.asStateFlow()
+
+// Update — .update {} is thread-safe (atomic CAS); prefer it over .value =
+_state.update { current -> current.copy(isLoading = true) }
+// .value = is fine only when you are certain you're on a single thread (e.g., Main)
+```
+
+### Collect (in screen — use repeatOnLifecycle)
+
+```kotlin
+viewLifecycleOwner.lifecycleScope.launch {
+    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+        viewModel.state.collect { state -> render(state) }
+    }
+}
+```
+
+### StateFlow Rules
+- Always one value — never null
+- Conflates: fast producers, slow collectors only see latest value
+- `distinctUntilChanged()` built-in — same value doesn't re-emit
+- Initial value required
+
+---
+
+## SharedFlow — Hot, Multiple Subscribers, Events
+
+```kotlin
+// One-shot events (navigation, snackbars)
+private val _effects = MutableSharedFlow<AuthEffect>(
+    extraBufferCapacity = 16,
+    onBufferOverflow = BufferOverflow.DROP_OLDEST,
+)
+val effects: SharedFlow<AuthEffect> = _effects.asSharedFlow()
+
+// Emit
+fun onLoginSuccess(user: User) {
+    viewModelScope.launch { _effects.emit(AuthEffect.NavigateToHome) }
+}
+```
+
+### Channel-Based Effects (Preferred for Single Collector)
+
+When there is exactly one collector (the screen), prefer `Channel` — it guarantees delivery and avoids replay concerns:
+
+```kotlin
+private val _effects = Channel<AuthEffect>(Channel.BUFFERED)
+val effects: Flow<AuthEffect> = _effects.receiveAsFlow()
+
+fun navigate() {
+    viewModelScope.launch { _effects.send(AuthEffect.NavigateToHome) }
+}
+
+// Screen — collect inside repeatOnLifecycle
+viewLifecycleOwner.lifecycleScope.launch {
+    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+        viewModel.effects.collect { effect -> handleEffect(effect) }
+    }
+}
+```
+
+### SharedFlow vs Channel
+
+| | `SharedFlow` | `Channel` |
+|---|---|---|
+| **Subscribers** | Many | One |
+| **Replay** | Configurable (default 0) | None |
+| **Use for** | Broadcast events | Single-consumer pipeline |
+
+---
+
+## stateIn / shareIn — Convert Cold to Hot
+
+```kotlin
+// stateIn — cold Flow → StateFlow (single current value)
+val latestUser: StateFlow<User?> = userRepository.observeUser()
+    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
+
+// shareIn — cold Flow → SharedFlow (multicast, configurable replay)
+val sharedEvents: SharedFlow<Event> = eventSource()
+    .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
+```
+
+---
+
+## Cancellation
+
+```kotlin
+// Check cancellation in CPU-bound loops
+suspend fun processWords(words: List<Word>): List<Result> {
+    return words.map { word ->
+        ensureActive()   // throws CancellationException if scope is cancelled
+        processWord(word)
+    }
+}
+
+// withTimeout
+val result = withTimeout(10_000) { fetchData() }  // TimeoutCancellationException on timeout
+val resultOrNull = withTimeoutOrNull(10_000) { fetchData() }  // null on timeout
+
+// CancellationException must always propagate
+try {
+    delay(1000)
+} catch (e: CancellationException) {
+    throw e   // always rethrow — cancellation must propagate
+} catch (e: Exception) {
+    handleError(e)
+}
+
+// NonCancellable — cleanup that must complete even on cancellation
+try {
+    doWork()
+} finally {
+    withContext(NonCancellable) { db.close() }
+}
+```
+
+### CoroutineExceptionHandler
+
+Handles uncaught exceptions from `launch {}` blocks (not `async {}`):
+
+```kotlin
+val handler = CoroutineExceptionHandler { _, throwable ->
+    logError("Unhandled coroutine exception", throwable)
+}
+
+viewModelScope.launch(handler) {
+    riskyOperation()   // exception caught by handler instead of crashing
+}
+```
+
+---
+
+## Testing Coroutines
+
+```kotlin
+class WordViewModelTest {
+
+    @get:Rule
+    val mainDispatcherRule = MainDispatcherRule()  // replaces Main with TestDispatcher
+
+    @Test
+    fun `loading words emits success state`() = runTest {
+        val fakeRepo = FakeWordRepository()
+        val vm = WordViewModel(GetWordsUseCase(fakeRepo))
+
+        // Turbine for Flow
+        vm.state.test {
+            val initial = awaitItem()
+            assertEquals(WordListState(), initial)
+
+            fakeRepo.emit(listOf(testWord()))
+            val loaded = awaitItem()
+            assertEquals(1, loaded.words.size)
+
+            cancelAndIgnoreRemainingEvents()
+        }
+    }
+}
+
+// MainDispatcherRule
+class MainDispatcherRule : TestWatcher() {
+    val testDispatcher = UnconfinedTestDispatcher()
+    override fun starting(d: Description) { Dispatchers.setMain(testDispatcher) }
+    override fun finished(d: Description) { Dispatchers.resetMain() }
+}
+```
+
+### UnconfinedTestDispatcher vs StandardTestDispatcher
+
+| | `UnconfinedTestDispatcher` | `StandardTestDispatcher` |
+|---|---|---|
+| **Execution** | Eager — runs coroutines inline immediately | Lazy — coroutines must be explicitly advanced |
+| **Use for** | Simple unit tests; quick state assertion | Time-sensitive tests; coroutines with `delay` |
+| **Requires** | Nothing extra | `advanceUntilIdle()` / `advanceTimeBy()` |
+
+Use `UnconfinedTestDispatcher` in `MainDispatcherRule` (simpler ViewModel tests). Use `StandardTestDispatcher` when you need control over virtual time.
+
+### runTest Controls
+
+```kotlin
+runTest {
+    // advanceTimeBy — virtual time
+    advanceTimeBy(5_000)
+    runCurrent()
+
+    // advanceUntilIdle — run all pending coroutines
+    advanceUntilIdle()
+
+    // TestCoroutineScheduler
+    testScheduler.advanceTimeBy(1000)
+}
+```
+
+---
+
+## Flows in Repository
+
+```kotlin
+// observeWords — converts Room Flow to domain Flow
+fun observeWords(): Flow<List<Word>> =
+    dao.observeAll()
+        .map { entities -> entities.map(WordEntity::toDomain) }
+        .catch { e -> emit(emptyList()) }  // never let DB errors crash the app
+        .flowOn(Dispatchers.IO)            // upstream on IO thread
+
+// Combine local + remote
+fun observeWordsWithSync(): Flow<List<Word>> =
+    combine(
+        dao.observeAll().map { it.map(WordEntity::toDomain) },
+        syncTrigger,
+    ) { local, _ -> local }
+```

+ 452 - 0
.claude/skills/dependency-injection/SKILL.md

@@ -0,0 +1,452 @@
+---
+name: dependency-injection
+description: Wire Android/KMP apps with Koin 4.x or Hilt — module declarations, scopes, qualifiers, ViewModel injection, KMP multiplatform modules, and testing with fakes
+argument-hint: "<component or feature to wire with DI>"
+user-invocable: true
+allowed-tools: ["Read", "Write", "Edit", "Glob", "Grep"]
+---
+
+# Dependency Injection — Koin 4.x & Hilt
+
+## Koin 4.x (Recommended for KMP)
+
+### Setup
+
+```kotlin
+// shared/build.gradle.kts
+dependencies {
+    implementation(libs.koin.core)
+    implementation(libs.koin.compose)              // koin-compose
+    implementation(libs.koin.compose.viewmodel)    // koinViewModel()
+}
+
+// androidApp/build.gradle.kts
+dependencies {
+    implementation(libs.koin.android)
+}
+```
+
+### Module Declaration
+
+```kotlin
+// commonMain — shared business logic DI
+val domainModule = module {
+    // Use Cases — factory (new instance each time)
+    factory { GetDueWordsUseCase(get()) }
+    factory { ReviewWordUseCase(get(), get()) }
+    factory { SaveWordUseCase(get()) }
+
+    // Domain Services — single (shared, stateless)
+    single { SpacedRepetitionService() }
+}
+
+val dataModule = module {
+    // Repository — single (shared state across features)
+    // Impl returns Try<T> as mandated by the layer contract
+    single<IWordRepository> { WordRepositoryImpl(get(), get()) }
+    single<IUserRepository> { UserRepositoryImpl(get(), get()) }
+
+    // DataSources — single
+    single<IWordLocalDataSource> { WordLocalDataSourceImpl(get()) }
+    single<IWordRemoteDataSource> { WordRemoteDataSourceImpl(get()) }
+}
+
+val networkModule = module {
+    single { createHttpClient(get()) }
+}
+```
+
+### ViewModel Injection
+
+```kotlin
+// commonMain — declare with viewModel {}
+val presentationModule = module {
+    viewModel { WordListViewModel(get(), get()) }
+    viewModel { StudyViewModel(get(), get(), get()) }
+    // SavedStateHandle is injected automatically by Koin
+    viewModel { WordDetailViewModel(get(), get<SavedStateHandle>()) }
+}
+
+// Screen — inject with koinViewModel()
+@Composable
+fun WordListScreen() {
+    val viewModel = koinViewModel<WordListViewModel>()
+    // ...
+}
+
+// Screen with navigation argument via SavedStateHandle
+@Composable
+fun WordDetailScreen() {
+    // wordId read from SavedStateHandle inside the ViewModel
+    val viewModel = koinViewModel<WordDetailViewModel>()
+}
+```
+
+### Platform Modules — `expect/actual` Pattern
+
+```kotlin
+// commonMain — declare the contract
+expect val platformModule: Module
+
+// androidMain
+actual val platformModule: Module = module {
+    single<HttpClientEngine> { OkHttp.create() }
+    single { DatabaseDriverFactory(androidContext()) }
+    single<IPlatformNotifications> { AndroidNotificationsImpl(androidContext()) }
+    single<ISecureStorage> { AndroidSecureStorageImpl(androidContext()) }
+}
+
+// iosMain
+actual val platformModule: Module = module {
+    single<HttpClientEngine> { Darwin.create() }
+    single { DatabaseDriverFactory() }
+    single<IPlatformNotifications> { IosNotificationsImpl() }
+    single<ISecureStorage> { IosKeychainStorageImpl() }
+}
+```
+
+### App Initialization
+
+```kotlin
+// Android — Application.onCreate()
+class LexiconApp : Application() {
+    override fun onCreate() {
+        super.onCreate()
+        startKoin {
+            androidContext(this@LexiconApp)
+            androidLogger(Level.ERROR)    // Level.DEBUG in development
+            modules(
+                domainModule,
+                dataModule,
+                networkModule,
+                presentationModule,
+                platformModule,           // resolves to androidMain actual
+            )
+        }
+    }
+}
+
+// iOS — Swift entry point (call from Swift AppDelegate / @main)
+fun initKoin() {
+    startKoin {
+        modules(
+            domainModule,
+            dataModule,
+            networkModule,
+            presentationModule,
+            platformModule,               // resolves to iosMain actual
+        )
+    }
+}
+```
+
+```swift
+// Swift — AppDelegate.swift
+@main
+struct LexiconApp: App {
+    init() { KoinKt.doInitKoin() }
+}
+```
+
+---
+
+## Koin Scopes
+
+### Feature Scopes — Lifetime Tied to a Feature
+
+```kotlin
+// Define scope
+val featureModule = module {
+    scope<StudyScope> {
+        scoped { StudySessionManager(get()) }
+        scoped { StreakTracker(get()) }
+        viewModel { StudyViewModel(get(), get()) }
+    }
+}
+
+// Android — create and close with lifecycle
+class StudyActivity : AppCompatActivity() {
+    private val scope: Scope by lazy {
+        getKoin().createScope<StudyScope>()
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        scope.close()
+    }
+}
+```
+
+---
+
+## Module Composition with `includes()`
+
+Use `includes()` to reuse modules inside feature modules without re-declaring bindings.
+
+```kotlin
+val studyFeatureModule = module {
+    includes(domainModule, dataModule)
+    viewModel { StudyViewModel(get()) }
+}
+```
+
+---
+
+## Qualifiers — Multiple Implementations
+
+```kotlin
+// Define qualifier constants
+val IoDispatcher      = named("IoDispatcher")
+val DefaultDispatcher = named("DefaultDispatcher")
+val MainDispatcher    = named("MainDispatcher")
+
+val dispatchersModule = module {
+    single(IoDispatcher)      { Dispatchers.IO }
+    single(DefaultDispatcher) { Dispatchers.Default }
+    single(MainDispatcher)    { Dispatchers.Main }
+}
+
+// In module
+single<IWordRepository> {
+    WordRepositoryImpl(
+        ioDispatcher = get(IoDispatcher),
+    )
+}
+```
+
+---
+
+## Lazy Injection & Delegation
+
+Use `KoinComponent` only in platform/infrastructure layer classes — never in domain.
+
+```kotlin
+// data / platform layer only
+class WordNotificationService : KoinComponent {
+    private val repository: IWordRepository by inject()   // lazy
+    private val settings: AppSettings by inject()
+}
+
+// Direct get — resolve immediately
+class AppInitializer : KoinComponent {
+    fun init() {
+        val settings: AppSettings = get()
+        settings.migrate()
+    }
+}
+```
+
+---
+
+## Testing with Koin
+
+### Graph Verification
+
+Catch missing bindings at test time — run once per module set.
+
+```kotlin
+class KoinGraphTest : KoinTest {
+    @Test
+    fun `verify full koin graph`() {
+        val app = KoinApplication.init()
+        app.modules(domainModule, dataModule, networkModule, presentationModule, platformModule)
+        app.checkModules()
+    }
+}
+```
+
+### Unit Tests — Prefer Constructor Injection (No Koin)
+
+```kotlin
+// Best — direct construction, no Koin overhead
+class WordListViewModelTest {
+    private val fakeRepo = FakeWordRepository()
+    private val useCase  = GetDueWordsUseCase(fakeRepo)
+    private val vm       = WordListViewModel(useCase)
+
+    @Test
+    fun `loading state transitions correctly`() = runTest { ... }
+}
+```
+
+### Integration Tests — KoinTest
+
+```kotlin
+class WordListIntegrationTest : KoinTest {
+
+    @Before
+    fun setUp() {
+        startKoin {
+            modules(
+                module {
+                    single<IWordRepository> { FakeWordRepository() }
+                    single { GetDueWordsUseCase(get()) }
+                    viewModel { WordListViewModel(get()) }
+                }
+            )
+        }
+    }
+
+    @After
+    fun tearDown() = stopKoin()
+
+    @Test
+    fun `loading state transitions correctly`() = runTest {
+        val viewModel: WordListViewModel by inject()
+        // test...
+    }
+}
+```
+
+---
+
+## Hilt (Android-only projects)
+
+### Setup
+
+```kotlin
+// app/build.gradle.kts
+plugins {
+    id("com.google.dagger.hilt.android")
+    id("com.google.devtools.ksp")
+}
+
+dependencies {
+    implementation(libs.hilt.android)
+    ksp(libs.hilt.compiler)
+    implementation(libs.hilt.navigation.compose)
+}
+```
+
+### Module Declaration
+
+```kotlin
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+    @Provides @Singleton
+    fun provideHttpClient(): OkHttpClient = OkHttpClient.Builder()
+        .connectTimeout(15, TimeUnit.SECONDS)
+        .build()
+
+    @Provides @Singleton
+    fun provideWordApiService(client: OkHttpClient): WordApiService =
+        Retrofit.Builder()
+            .baseUrl(BuildConfig.API_BASE_URL)
+            .client(client)
+            .addConverterFactory(kotlinx.serialization converter)
+            .build()
+            .create(WordApiService::class.java)
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RepositoryModule {
+    @Binds @Singleton
+    abstract fun bindWordRepository(impl: WordRepositoryImpl): IWordRepository
+}
+```
+
+### ViewModel Injection with Hilt
+
+```kotlin
+@HiltViewModel
+class WordListViewModel @Inject constructor(
+    private val getDueWords: GetDueWordsUseCase,
+    private val deleteWord: DeleteWordUseCase,
+) : ViewModel() { ... }
+
+// ViewModel with navigation argument via SavedStateHandle
+@HiltViewModel
+class WordDetailViewModel @Inject constructor(
+    savedStateHandle: SavedStateHandle,       // injected automatically from NavBackStackEntry
+    private val getWord: GetWordUseCase,
+) : ViewModel() {
+    private val wordId: Int = checkNotNull(savedStateHandle["wordId"])
+}
+
+// Screen — no factory needed
+@Composable
+fun WordListScreen(
+    viewModel: WordListViewModel = hiltViewModel(),
+) { ... }
+```
+
+### Scoped Components
+
+```kotlin
+// Activity-scoped — destroyed with activity
+@Module
+@InstallIn(ActivityComponent::class)
+object ActivityModule { ... }
+
+// ViewModel-scoped — destroyed with ViewModel
+@Module
+@InstallIn(ViewModelComponent::class)
+object ViewModelModule { ... }
+```
+
+### Testing with Hilt
+
+```kotlin
+@HiltAndroidTest
+class WordListViewModelTest {
+    @get:Rule(order = 0)
+    val hiltRule = HiltAndroidRule(this)
+
+    @get:Rule(order = 1)
+    val mainDispatcherRule = MainDispatcherRule()
+
+    // Field injection is required here — Hilt test infrastructure does not support
+    // constructor injection for @HiltAndroidTest classes
+    @Inject lateinit var viewModel: WordListViewModel
+
+    @Before
+    fun setUp() = hiltRule.inject()
+}
+
+// Replace bindings in tests
+@Module
+@TestInstallIn(
+    components = [SingletonComponent::class],
+    replaces = [RepositoryModule::class],
+)
+abstract class FakeRepositoryModule {
+    @Binds @Singleton
+    abstract fun bindFakeWordRepository(fake: FakeWordRepository): IWordRepository
+}
+```
+
+---
+
+## Layer Contract Reminder
+
+DI wires implementations to interfaces — the return types must match the layer contract from CLAUDE.md:
+
+| Binding | Interface Return Type |
+|---|---|
+| `IWordRepository.getWord()` | `Try<Word>` |
+| `IWordRepository.observeWords()` | `Flow<List<Word>>` |
+| UseCase | `Try<T>` or `Flow<T>` |
+
+Never let the DI module paper over a missing `Try<T>` — the interface must enforce it.
+
+---
+
+## DI Decision Guide
+
+| Need | Choice |
+|---|---|
+| KMP (Android + iOS) | Koin 4.x + `expect/actual` platform modules |
+| Android-only, large team | Hilt (compile-time safety) |
+| Android-only, simple app | Koin (less boilerplate) |
+| Unit testing | Constructor injection — no DI framework |
+
+## Anti-Patterns
+
+- Never inject `Context` into domain or data layer classes — pass at app boundary
+- Never use `GlobalContext.get()` / `KoinComponent` in domain classes — breaks portability
+- Never use field injection when constructor injection is possible (Hilt `@HiltAndroidTest` is the sole exception)
+- Never declare `single {}` for `ViewModel` — use `viewModel {}` for proper scope
+- Avoid circular dependencies — restructure to break cycles
+- Never skip `checkModules()` in CI — a missing binding will crash at runtime, not compile time

+ 582 - 0
.claude/skills/jetpack-compose/SKILL.md

@@ -0,0 +1,582 @@
+---
+name: jetpack-compose
+description: Write idiomatic Jetpack Compose UI — stateless composables, state hoisting, slot API, Modifier ordering, side effects, previews, accessibility, and custom layouts
+argument-hint: "<component or screen to build>"
+user-invocable: true
+allowed-tools: ["Read", "Write", "Edit", "Glob", "Grep"]
+---
+
+# Jetpack Compose — Best Practices
+
+## Composable Design
+
+### Stateless vs Stateful — Always Hoist State
+
+```kotlin
+// Stateless — preferred: reusable, testable, previewable
+@Composable
+fun WordCard(
+    word: Word,
+    isFlipped: Boolean,
+    onFlip: () -> Unit,
+    onDelete: () -> Unit,
+    modifier: Modifier = Modifier,
+) { ... }
+
+// Stateful — only at ViewModel boundary
+// viewModel.state() is a project-level extension:
+//   fun <S> ViewModel.state(): State<S> = uiState.collectAsStateWithLifecycle()
+@Composable
+fun WordCardScreen() {
+    val viewModel = koinViewModel<WordCardViewModel>()
+    val state by viewModel.state()
+    WordCard(
+        word      = state.word,
+        isFlipped = state.isFlipped,
+        onFlip    = viewModel::flip,
+        onDelete  = viewModel::delete,
+    )
+}
+```
+
+**State hoisting rule**: hoist state to the lowest common ancestor of all composables that need to read or write it.
+
+### Slot API
+
+Design containers with content slots instead of bloated parameter lists:
+
+```kotlin
+@Composable
+fun SectionCard(
+    modifier: Modifier = Modifier,
+    header: @Composable (() -> Unit)? = null,
+    footer: @Composable (() -> Unit)? = null,
+    content: @Composable ColumnScope.() -> Unit,
+) {
+    Card(modifier = modifier) {
+        Column(Modifier.padding(16.dp)) {
+            header?.invoke()
+            content()
+            footer?.invoke()
+        }
+    }
+}
+
+// Usage — flexible without combinatorial parameter explosion
+SectionCard(
+    header = { Text("Recent Words", style = MaterialTheme.typography.titleMedium) },
+    footer = { TextButton(onClick = { }) { Text("See all") } },
+) {
+    words.forEach { WordRow(it) }
+}
+```
+
+---
+
+## Modifier
+
+### Always Accept `modifier: Modifier = Modifier`
+
+Every composable that renders UI must accept and forward a modifier parameter:
+
+```kotlin
+@Composable
+fun PrimaryButton(
+    text: String,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,   // always last named param before content
+    enabled: Boolean = true,
+) {
+    Button(onClick = onClick, modifier = modifier, enabled = enabled) {
+        Text(text)
+    }
+}
+```
+
+### Modifier Ordering Matters
+
+Applied left-to-right — order changes visual and touch behaviour:
+
+```kotlin
+// Clickable area INCLUDES the padding
+Modifier
+    .clickable { onClick() }
+    .padding(16.dp)
+
+// Padding is OUTSIDE the clickable area
+Modifier
+    .padding(16.dp)
+    .clickable { onClick() }
+
+// Canonical ordering for a card-like component
+Modifier
+    .fillMaxWidth()
+    .clip(RoundedCornerShape(12.dp))
+    .background(containerColor)
+    .clickable { onClick() }
+    .padding(horizontal = 16.dp, vertical = 12.dp)
+```
+
+---
+
+## Lazy Lists
+
+Always supply `key` and `contentType` to help the Compose runtime skip unnecessary work:
+
+```kotlin
+LazyColumn {
+    items(
+        items       = words,
+        key         = { it.id },          // stable identity — enables animated reordering
+        contentType = { "word" },         // same type → runtime reuses composition nodes
+    ) { word ->
+        WordRow(
+            word     = word,
+            modifier = Modifier.animateItem(),  // smooth add/remove animations for free
+        )
+    }
+}
+
+// key() in non-lazy forEach — same principle
+Column {
+    words.forEach { word ->
+        key(word.id) {
+            WordRow(word)
+        }
+    }
+}
+```
+
+Rules:
+- `key` must be **stable and unique** — avoid array indices as keys
+- Omitting `key` forces the runtime to diff by position; inserting at the top recomposes everything
+- `contentType` matters when a list mixes heterogeneous item types (headers, ads, rows)
+
+---
+
+## State Management
+
+### remember and Keys
+
+```kotlin
+// No key — computed once
+val painter = remember { loadPainter(url) }
+
+// Key — recomputed when id changes
+val formatted = remember(id) { expensiveFormat(id) }
+
+// Multiple keys
+val result = remember(a, b) { compute(a, b) }
+```
+
+### derivedStateOf — Avoid Redundant Recomposition
+
+```kotlin
+// Only recomposes when the boolean flips, not on every scroll delta
+val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
+
+// Multiple conditions
+val isFormValid by remember {
+    derivedStateOf { name.isNotBlank() && email.contains("@") && password.length >= 8 }
+}
+```
+
+### rememberSaveable — Survive Process Death
+
+```kotlin
+// Survives recomposition AND process death (uses Bundle)
+var searchQuery by rememberSaveable { mutableStateOf("") }
+
+// Custom saver for complex types
+var selection by rememberSaveable(stateSaver = SelectionSaver) { mutableStateOf(Selection.None) }
+```
+
+### Deferred State Reads — Lambda Modifiers
+
+Reading state inside a lambda modifier defers the read to the draw/layout phase, bypassing recomposition entirely for continuous animations:
+
+```kotlin
+// BAD — state read triggers recomposition on every frame
+val offsetY by animateFloatAsState(if (expanded) 0f else -100f)
+Box(Modifier.offset(y = offsetY.dp))
+
+// GOOD — state read deferred to layout phase, no recomposition
+val offsetY by animateFloatAsState(if (expanded) 0f else -100f)
+Box(Modifier.offset { IntOffset(0, offsetY.roundToInt()) })
+
+// Same pattern for graphicsLayer
+Box(Modifier.graphicsLayer {
+    alpha       = alphaState.value
+    scaleX      = scaleState.value
+    scaleY      = scaleState.value
+    translationY = translationState.value
+})
+```
+
+---
+
+## Stability — @Stable, @Immutable, ImmutableList
+
+The Compose compiler skips recomposition only if it can prove all parameters are **stable**. Unstable parameters force recomposition even when values haven't changed.
+
+```kotlin
+// @Immutable — all public properties are deeply immutable; compiler treats as stable
+@Immutable
+data class Word(val id: String, val text: String, val definition: String)
+
+// @Stable — mutable but notifies Compose when values change (e.g., custom observable)
+@Stable
+class WordSelection {
+    var selectedId by mutableStateOf<String?>(null)
+}
+
+// ImmutableList — List<T> is unstable because its interface allows mutation
+// Use kotlinx.collections.immutable
+@Immutable
+data class WordListState(
+    val words: ImmutableList<Word>,    // stable ✓
+    val isLoading: Boolean,
+)
+
+// Building ImmutableList
+val state = WordListState(
+    words     = words.toImmutableList(),
+    isLoading = false,
+)
+```
+
+Rules:
+- `data class` with only `val` primitive/`@Immutable` properties is automatically stable — no annotation needed
+- `List<T>`, `Map<K, V>` are **never stable** — always wrap with `ImmutableList` / `ImmutableMap` in state classes
+- Use the Compose compiler metrics (`./gradlew assembleRelease -PcomposeCompilerReports=true`) to verify stability
+
+---
+
+## Side Effects
+
+| Effect | Use case |
+|---|---|
+| `LaunchedEffect(key)` | Launch coroutine, re-launch when key changes |
+| `DisposableEffect(key)` | Register + cleanup (listeners, observers) |
+| `SideEffect` | Sync Compose state to non-Compose system every recomposition |
+| `rememberUpdatedState` | Capture latest value inside long-lived effect |
+| `produceState` | Convert callback/non-Compose source to State |
+| `snapshotFlow` | Convert Compose `State` to `Flow` |
+
+```kotlin
+// LaunchedEffect — load data when id changes
+LaunchedEffect(wordId) {
+    viewModel.loadWord(wordId)
+}
+
+// DisposableEffect — register/unregister lifecycle observer
+DisposableEffect(lifecycle) {
+    val observer = LifecycleEventObserver { _, event -> onEvent(event) }
+    lifecycle.addObserver(observer)
+    onDispose { lifecycle.removeObserver(observer) }
+}
+
+// rememberUpdatedState — always use latest callback in a running effect
+val currentOnTimeout by rememberUpdatedState(onTimeout)
+LaunchedEffect(Unit) {
+    delay(5_000)
+    currentOnTimeout()   // calls latest lambda, not the one captured at launch
+}
+
+// produceState — wrap callback API
+val bitmap by produceState<Bitmap?>(initialValue = null, url) {
+    value = loadBitmapAsync(url)
+}
+```
+
+---
+
+## ViewModel Effects — One-Shot Events
+
+**Never** use `LaunchedEffect` to observe a navigation or snackbar event — it can fire multiple times on recomposition. Use an `OnEvents` pattern that consumes each event exactly once:
+
+```kotlin
+// In ViewModel — emit to SharedFlow
+class WordCardViewModel : ViewModel() {
+    private val _effects = MutableSharedFlow<WordEffect>(extraBufferCapacity = 1)
+    val effects: SharedFlow<WordEffect> = _effects.asSharedFlow()
+
+    fun delete() {
+        viewModelScope.launch {
+            repository.delete(state.word.id)
+            _effects.emit(WordEffect.NavigateBack)
+        }
+    }
+}
+
+sealed interface WordEffect {
+    data object NavigateBack : WordEffect
+    data class ShowError(val message: String) : WordEffect
+}
+
+// In composable — collect effects exactly once
+@Composable
+fun WordCardScreen(
+    onNavigateBack: () -> Unit,
+    snackbarHostState: SnackbarHostState,
+) {
+    val viewModel = koinViewModel<WordCardViewModel>()
+
+    // OnEvents: collect SharedFlow without a key so it never re-subscribes
+    val effects = viewModel.effects
+    LaunchedEffect(effects) {
+        effects.collect { effect ->
+            when (effect) {
+                WordEffect.NavigateBack        -> onNavigateBack()
+                is WordEffect.ShowError        -> snackbarHostState.showSnackbar(effect.message)
+            }
+        }
+    }
+
+    val state by viewModel.state()
+    WordCard(state = state, onDelete = viewModel::delete)
+}
+```
+
+---
+
+## Scaffold + SnackbarHost
+
+The canonical screen skeleton — always wire `SnackbarHostState` through the call stack, not via `CompositionLocal`:
+
+```kotlin
+@Composable
+fun WordListScreen(onWordClick: (String) -> Unit) {
+    val snackbarHostState = remember { SnackbarHostState() }
+    val viewModel = koinViewModel<WordListViewModel>()
+
+    // Consume effects (see ViewModel Effects section)
+    LaunchedEffect(viewModel.effects) {
+        viewModel.effects.collect { effect ->
+            when (effect) {
+                is WordListEffect.ShowError -> snackbarHostState.showSnackbar(effect.message)
+            }
+        }
+    }
+
+    Scaffold(
+        topBar = {
+            TopAppBar(title = { Text("Words") })
+        },
+        floatingActionButton = {
+            FloatingActionButton(onClick = viewModel::addWord) {
+                Icon(Icons.Default.Add, contentDescription = "Add word")
+            }
+        },
+        snackbarHost = { SnackbarHost(snackbarHostState) },
+    ) { innerPadding ->
+        val state by viewModel.state()
+        WordList(
+            words       = state.words,
+            onWordClick = onWordClick,
+            modifier    = Modifier.padding(innerPadding),
+        )
+    }
+}
+```
+
+---
+
+## Animation
+
+Prefer high-level animation APIs before reaching for `Animatable` or `Transition`:
+
+```kotlin
+// AnimatedVisibility — show/hide with enter/exit transitions
+AnimatedVisibility(
+    visible = showBanner,
+    enter   = fadeIn() + expandVertically(),
+    exit    = fadeOut() + shrinkVertically(),
+) {
+    ErrorBanner(message)
+}
+
+// animateContentSize — smooth size changes without measuring manually
+Column(Modifier.animateContentSize()) {
+    Text(text, maxLines = if (expanded) Int.MAX_VALUE else 2)
+    TextButton(onClick = { expanded = !expanded }) {
+        Text(if (expanded) "Show less" else "Show more")
+    }
+}
+
+// animateFloatAsState — single value animation driven by state
+val alpha by animateFloatAsState(
+    targetValue = if (isLoading) 0.4f else 1f,
+    label       = "content alpha",
+)
+Box(Modifier.graphicsLayer { this.alpha = alpha }) { ... }
+
+// Crossfade — swap between two composables
+Crossfade(targetState = currentScreen, label = "screen") { screen ->
+    when (screen) {
+        Screen.List   -> WordListScreen()
+        Screen.Detail -> WordDetailScreen()
+    }
+}
+```
+
+---
+
+## Previews
+
+Every public composable needs at minimum a light + dark preview:
+
+```kotlin
+@Preview(showBackground = true, name = "Light")
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark")
+@Composable
+private fun WordCardPreview() {
+    AppTheme {
+        WordCard(
+            word      = previewWord(),
+            isFlipped = false,
+            onFlip    = {},
+            onDelete  = {},
+        )
+    }
+}
+
+// Device-size previews for adaptive layouts
+@Preview(name = "Phone",  device = Devices.PHONE)
+@Preview(name = "Tablet", device = Devices.TABLET)
+@Composable
+private fun WordListPreview() { ... }
+
+// @PreviewParameter — multiple data variants from a single preview function
+class WordPreviewProvider : PreviewParameterProvider<Word> {
+    override val values = sequenceOf(
+        Word(id = "1", text = "Ephemeral", definition = "Lasting for a very short time"),
+        Word(id = "2", text = "X", definition = ""),           // edge case: short text
+        Word(id = "3", text = "A".repeat(40), definition = "A".repeat(200)), // overflow
+    )
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun WordRowPreview(@PreviewParameter(WordPreviewProvider::class) word: Word) {
+    AppTheme { WordRow(word) }
+}
+```
+
+Rules:
+- Always wrap in the app theme
+- Use realistic preview data — not empty strings or placeholder IDs
+- `private` visibility — previews are not API
+- Use `@PreviewParameter` for edge cases (empty, overflow, RTL) rather than duplicating preview functions
+
+---
+
+## CompositionLocal
+
+For cross-cutting concerns that would require threading through many composable levels.
+
+**Choose the right variant:**
+- `staticCompositionLocalOf` — value is expected to never (or rarely) change; **any** change invalidates the entire subtree. Best for stable dependencies (analytics, feature flags).
+- `compositionLocalOf` — tracks reads and only recomposes consumers when the value changes. Use when the value updates at runtime (e.g., a dynamic theme override).
+
+```kotlin
+// staticCompositionLocalOf — stable dependency, never changes after app start
+val LocalAnalytics = staticCompositionLocalOf<Analytics> {
+    error("No Analytics provided — wrap with CompositionLocalProvider")
+}
+
+// compositionLocalOf — changes at runtime (only recomposes readers)
+val LocalContentAlpha = compositionLocalOf { 1f }
+
+// Provide (at app root or feature entry)
+CompositionLocalProvider(LocalAnalytics provides analytics) {
+    AppContent()
+}
+
+// Consume (anywhere in subtree)
+val analytics = LocalAnalytics.current
+```
+
+Good candidates: theme, navigation, snackbar host, analytics, feature flags.
+Bad candidates: screen-specific state, business data — pass those explicitly.
+
+---
+
+## Accessibility
+
+```kotlin
+// Role for custom clickable
+Box(
+    modifier = Modifier
+        .semantics { role = Role.Button }
+        .clickable(onClickLabel = "Delete word") { onDelete() }
+) { ... }
+
+// contentDescription rules:
+//   - Interactive icons (standalone Icon button): always set a description
+//   - Decorative icons inside a labeled component: set null so TalkBack skips the icon
+Icon(
+    imageVector        = Icons.Default.Delete,
+    contentDescription = "Delete",   // standalone — screen reader announces this
+)
+
+// Merge semantics for compound components (icon + label)
+// Set null on the icon so TalkBack reads "Learned" once, not "check mark, Learned"
+Row(Modifier.semantics(mergeDescendants = true) {}) {
+    Icon(Icons.Default.Check, contentDescription = null)
+    Text("Learned")
+}
+
+// Custom actions for swipeable items — expose actions to TalkBack without swiping
+Box(Modifier.semantics {
+    customActions = listOf(
+        CustomAccessibilityAction("Mark as learned") { viewModel.markLearned(); true },
+        CustomAccessibilityAction("Delete")          { viewModel.delete(); true },
+    )
+}) { ... }
+```
+
+---
+
+## Custom Layouts
+
+For non-standard arrangements not achievable with `Row`/`Column`/`Box`:
+
+```kotlin
+@Composable
+fun StaggeredRow(
+    modifier: Modifier = Modifier,
+    content: @Composable () -> Unit,
+) {
+    Layout(content = content, modifier = modifier) { measurables, constraints ->
+        val placeables = measurables.map { it.measure(constraints) }
+        val width  = constraints.maxWidth
+        val height = placeables.maxOf { it.height }
+
+        layout(width, height) {
+            var x = 0
+            placeables.forEachIndexed { index, placeable ->
+                val y = if (index % 2 == 0) 0 else placeable.height / 2
+                placeable.placeRelative(x, y)
+                x += placeable.width
+            }
+        }
+    }
+}
+```
+
+---
+
+## Performance Checklist
+
+- [ ] All composables accept `modifier: Modifier = Modifier` — see [Modifier](#modifier)
+- [ ] State hoisted to lowest common ancestor — see [Composable Design](#composable-design)
+- [ ] `remember` with keys for expensive computations — see [State Management](#state-management)
+- [ ] `derivedStateOf` for boolean flags derived from other state — see [State Management](#state-management)
+- [ ] `key` and `contentType` in every `LazyColumn`/`LazyRow` — see [Lazy Lists](#lazy-lists)
+- [ ] `key()` in non-lazy `forEach` loops — see [Lazy Lists](#lazy-lists)
+- [ ] No lambda/object allocation in composable body without `remember`
+- [ ] State reads deferred into lambda modifiers (`offset {}`, `graphicsLayer {}`) — see [Deferred State Reads](#deferred-state-reads--lambda-modifiers)
+- [ ] State/data classes annotated `@Stable` or `@Immutable` where needed — see [Stability](#stability----stable-immutable-immutablelist)
+- [ ] `ImmutableList` / `ImmutableMap` for collections in state classes — see [Stability](#stability----stable-immutable-immutablelist)
+- [ ] One-shot effects use `SharedFlow`, not `Channel` or `LaunchedEffect` — see [ViewModel Effects](#viewmodel-effects--one-shot-events)

+ 400 - 0
.claude/skills/kmp-patterns/SKILL.md

@@ -0,0 +1,400 @@
+---
+name: kmp-patterns
+description: Build Kotlin Multiplatform (KMP) shared code — source sets, expect/actual, platform bridges, KMP-compatible libraries, and iOS/Android interop patterns
+argument-hint: "<KMP feature or platform bridge to implement>"
+user-invocable: true
+allowed-tools: ["Read", "Write", "Edit", "Glob", "Grep"]
+---
+
+# Kotlin Multiplatform (KMP) Patterns
+
+## Source Set Structure
+
+```
+composeApp/src/
+├── commonMain/kotlin/      ← shared business logic, UI (Compose MP)
+├── androidMain/kotlin/     ← Android-specific implementations
+├── iosMain/kotlin/         ← iOS-specific implementations (Kotlin/Native)
+├── commonTest/kotlin/      ← shared tests (kotlin-test)
+├── androidTest/kotlin/     ← Android instrumented tests
+└── iosTest/kotlin/         ← iOS-specific tests (rare)
+```
+
+### build.gradle.kts Source Sets
+
+```kotlin
+kotlin {
+    androidTarget {
+        compilations.all {
+            kotlinOptions.jvmTarget = "11"
+        }
+    }
+    listOf(
+        iosX64(), iosArm64(), iosSimulatorArm64()
+    ).forEach { target ->
+        target.binaries.framework {
+            baseName = "ComposeApp"
+            isStatic = true
+        }
+    }
+
+    sourceSets {
+        commonMain.dependencies {
+            implementation(libs.kotlinx.coroutines.core)
+            implementation(libs.kotlinx.serialization.json)
+            implementation(libs.kotlinx.datetime)
+            implementation(libs.ktor.client.core)
+            implementation(libs.koin.core)
+            implementation(libs.koin.compose.viewmodel)   // viewModel {} DSL in commonMain
+            implementation(libs.sqldelight.runtime)
+        }
+        androidMain.dependencies {
+            implementation(libs.ktor.client.okhttp)
+            implementation(libs.sqldelight.android.driver)
+            implementation(libs.koin.android)
+        }
+        iosMain.dependencies {
+            implementation(libs.ktor.client.darwin)
+            implementation(libs.sqldelight.native.driver)
+        }
+        commonTest.dependencies {
+            implementation(kotlin("test"))
+            implementation(libs.kotlinx.coroutines.test)
+            implementation(libs.turbine)
+        }
+    }
+}
+```
+
+---
+
+## expect / actual Pattern
+
+### Simple Value
+
+```kotlin
+// commonMain — declaration
+expect val platformName: String
+
+// androidMain — implementation
+actual val platformName: String = "Android ${android.os.Build.VERSION.SDK_INT}"
+
+// iosMain — implementation
+actual val platformName: String = UIDevice.currentDevice.systemName()
+```
+
+### Class with Platform Behaviour
+
+```kotlin
+// commonMain
+expect class PlatformCrypto() {
+    fun hash(input: String): String
+    fun generateSecureToken(): String
+}
+
+// androidMain
+actual class PlatformCrypto actual constructor() {
+    actual fun hash(input: String): String =
+        MessageDigest.getInstance("SHA-256")
+            .digest(input.toByteArray())
+            .fold("") { acc, b -> acc + "%02x".format(b) }
+
+    actual fun generateSecureToken(): String =
+        java.util.UUID.randomUUID().toString()
+}
+
+// iosMain
+actual class PlatformCrypto actual constructor() {
+    actual fun hash(input: String): String {
+        val data = input.encodeToByteArray().toNSData()
+        val digest = UByteArray(CC_SHA256_DIGEST_LENGTH.toInt())
+        digest.usePinned { pinned ->
+            CC_SHA256(data.bytes, data.length.toUInt(), pinned.addressOf(0))
+        }
+        return digest.joinToString("") { it.toString(16).padStart(2, '0') }
+    }
+    actual fun generateSecureToken(): String = NSUUID().UUIDString
+}
+// Note: import platform.CoreCrypto.CC_SHA256, platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
+```
+
+### Interface-based Platform Bridge (preferred for testability)
+
+```kotlin
+// commonMain/platform/IPlatformNotifications.kt
+interface IPlatformNotifications {
+    fun scheduleReviewReminder(wordCount: Int, delayMinutes: Int)
+    fun cancelAll()
+}
+
+expect fun createPlatformNotifications(): IPlatformNotifications
+
+// androidMain
+actual fun createPlatformNotifications(): IPlatformNotifications =
+    AndroidNotificationsImpl()   // uses WorkManager / AlarmManager
+
+// iosMain
+actual fun createPlatformNotifications(): IPlatformNotifications =
+    IosNotificationsImpl()       // uses UNUserNotificationCenter
+```
+
+### actual typealias — Wrapping Platform Types
+
+The most ergonomic pattern when you want to expose a platform type through a common interface:
+
+```kotlin
+// commonMain
+expect class PlatformContext
+
+// androidMain
+actual typealias PlatformContext = android.content.Context
+
+// iosMain — wrap with a no-arg class (NSObject or custom)
+actual typealias PlatformContext = NSObject
+```
+
+Use `actual typealias` whenever the platform already has the exact class you need — avoid wrapping it in a new `actual class` unless the API needs to be shaped differently.
+
+> **Note:** `@OptionalExpectation` was deprecated in Kotlin 1.9. For Android-only annotations, prefer the `actual typealias` pattern above or a default no-op `actual` implementation.
+
+---
+
+## SQLDelight — Multiplatform Database
+
+### Driver Factory Pattern
+
+```kotlin
+// commonMain
+expect class DatabaseDriverFactory {
+    fun createDriver(): SqlDriver
+}
+
+// androidMain
+actual class DatabaseDriverFactory(private val context: Context) {
+    actual fun createDriver(): SqlDriver =
+        AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
+}
+
+// iosMain
+actual class DatabaseDriverFactory {
+    actual fun createDriver(): SqlDriver =
+        NativeSqliteDriver(AppDatabase.Schema, "app.db")
+}
+```
+
+### Schema Definition (.sq files)
+
+```sql
+-- commonMain/sqldelight/com/example/db/Word.sq
+CREATE TABLE IF NOT EXISTS Word (
+    id           INTEGER PRIMARY KEY AUTOINCREMENT,
+    original     TEXT NOT NULL,
+    translated   TEXT NOT NULL,
+    srs_level    INTEGER NOT NULL DEFAULT 0,
+    next_review  TEXT NOT NULL,
+    created_at   TEXT NOT NULL
+);
+
+getAllWords:
+SELECT * FROM Word ORDER BY next_review ASC;
+
+getDueWords:
+SELECT * FROM Word WHERE next_review <= :today ORDER BY next_review ASC;
+
+upsertWord:
+INSERT OR REPLACE INTO Word(id, original, translated, srs_level, next_review, created_at)
+VALUES (?, ?, ?, ?, ?, ?);
+
+deleteById:
+DELETE FROM Word WHERE id = :id;
+```
+
+---
+
+## Ktor — Multiplatform HTTP Client
+
+```kotlin
+// commonMain — shared client setup
+fun createHttpClient(engine: HttpClientEngine): HttpClient = HttpClient(engine) {
+    install(ContentNegotiation) {
+        json(Json {
+            ignoreUnknownKeys = true
+            isLenient = true
+        })
+    }
+    install(HttpTimeout) {
+        requestTimeoutMillis  = 30_000
+        connectTimeoutMillis  = 15_000
+    }
+    install(Logging) {
+        logger = Logger.DEFAULT
+        level  = if (isDebug) LogLevel.HEADERS else LogLevel.NONE  // see expect/actual below
+    }
+}
+
+// commonMain — isDebug bridge (BuildConfig.DEBUG is Android-only, don't use in commonMain)
+expect val isDebug: Boolean
+// androidMain: actual val isDebug: Boolean = BuildConfig.DEBUG
+// iosMain:     actual val isDebug: Boolean = Platform.isDebugBinary
+
+// DI — provide engine per platform
+// androidMain: HttpClient(OkHttp) { ... }
+// iosMain: HttpClient(Darwin) { ... }
+```
+
+---
+
+## kotlinx.datetime — Multiplatform Dates
+
+```kotlin
+import kotlinx.datetime.*
+
+// Current time
+val now: Instant     = Clock.System.now()
+val today: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault())
+
+// Arithmetic
+val tomorrow  = today.plus(1, DateTimeUnit.DAY)
+val nextWeek  = today.plus(DatePeriod(days = 7))
+val daysUntil = today.until(targetDate, DateTimeUnit.DAY)
+
+// Formatting (use kotlinx-datetime 0.6+)
+val formatted = today.format(LocalDate.Format {
+    monthName(MonthNames.ENGLISH_FULL)
+    chars(" ")
+    dayOfMonth()
+    chars(", ")
+    year()
+})
+```
+
+---
+
+## kotlinx.serialization
+
+```kotlin
+@Serializable
+data class WordDto(
+    val id: Int,
+    val original: String,
+    val translated: String,
+    @SerialName("srs_level") val srsLevel: Int = 0,
+    @SerialName("next_review") val nextReview: String? = null,
+)
+
+// Polymorphic serialization
+@Serializable
+sealed interface ApiResponse {
+    @Serializable @SerialName("success") data class Success(val data: WordDto) : ApiResponse
+    @Serializable @SerialName("error")   data class Error(val message: String) : ApiResponse
+}
+```
+
+---
+
+## Koin — Multiplatform DI
+
+```kotlin
+// commonMain DI module
+val commonModule = module {
+    single { createHttpClient(get()) }
+    single { WordRepositoryImpl(get(), get()) as IWordRepository }
+    single { GetDueWordsUseCase(get()) }
+    single { ReviewWordUseCase(get(), get()) }
+    viewModel { StudyViewModel(get(), get()) }
+}
+
+// androidMain DI module
+val androidModule = module {
+    single<HttpClientEngine> { OkHttp.create() }
+    single { DatabaseDriverFactory(get()) }
+}
+
+// iosMain DI module
+val iosModule = module {
+    single<HttpClientEngine> { Darwin.create() }
+    single { DatabaseDriverFactory() }
+}
+
+// iOS entry point — call from Swift: KoinHelperKt.doInitKoin()
+@OptIn(KoinExperimentalAPI::class)
+fun initKoin(): KoinApplication = startKoin {
+    modules(commonModule, iosModule)
+}
+// Swift: KoinHelperKt.doInitKoin() in AppDelegate / @main App.init
+```
+
+---
+
+## iOS Interop Tips
+
+### Coroutines → Swift Async
+
+Use `SKIE` (recommended) or `KMP-NativeCoroutines` to expose suspend functions as Swift async:
+
+```kotlin
+// With SKIE — zero boilerplate, check latest version at github.com/touchlab/SKIE
+// gradle (libs.versions.toml): skie = "0.10.x"
+// plugins { id("co.touchlab.skie") version libs.versions.skie.get() }
+suspend fun getWords(): List<Word>   // → async func getWords() -> [Word] in Swift
+fun observeWords(): Flow<List<Word>> // → AsyncStream<[Word]> in Swift
+```
+
+### @Throws — Surface Typed Errors to Swift
+
+Without `@Throws`, any exception from a suspend function becomes an untyped `KotlinThrowable` in Swift, losing all type information:
+
+```kotlin
+// commonMain
+@Throws(IOException::class, HttpException::class)
+suspend fun fetchUser(id: String): User
+
+// Swift — now catchable by type:
+// do { let user = try await repo.fetchUser(id: "123") }
+// catch let e as KotlinIOException { ... }
+```
+
+### Dispatchers.IO — Does Not Exist in commonMain/iOS
+
+`Dispatchers.IO` is JVM-only. Bridge it with expect/actual:
+
+```kotlin
+// commonMain
+expect val ioDispatcher: CoroutineDispatcher
+
+// androidMain
+actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
+
+// iosMain
+actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default
+```
+
+### Avoid These in commonMain
+
+- `java.*` imports — use `kotlinx.*` alternatives
+- `Thread.sleep` — use `kotlinx.coroutines.delay`
+- `System.currentTimeMillis()` — use `Clock.System.now()`
+- `java.util.UUID` — use platform-specific `expect`/`actual` or `com.benasher44:uuid`
+- `SimpleDateFormat` — use `kotlinx.datetime`
+- `BuildConfig.DEBUG` — use `expect val isDebug: Boolean` (see Ktor section)
+- `Dispatchers.IO` — JVM-only; use `expect val ioDispatcher` (see iOS Interop section)
+
+---
+
+## KMP-Compatible Library Checklist
+
+| Need | Library |
+|---|---|
+| HTTP client | `io.ktor:ktor-client-core` |
+| JSON | `org.jetbrains.kotlinx:kotlinx-serialization-json` |
+| Dates | `org.jetbrains.kotlinx:kotlinx-datetime` |
+| Database | `app.cash.sqldelight` |
+| DI | `io.insert-koin:koin-core` + `io.insert-koin:koin-compose-viewmodel` |
+| Settings | `com.russhwolf:multiplatform-settings` |
+| Logging | `co.touchlab:kermit` |
+| UUID | `com.benasher44:uuid` |
+| Immutable collections | `org.jetbrains.kotlinx:kotlinx-collections-immutable` |
+| Coroutines | `org.jetbrains.kotlinx:kotlinx-coroutines-core` |
+| File I/O | `com.squareup.okio:okio` |
+| Image loading | `io.coil-kt.coil3:coil-compose` (Coil 3.x — KMP native) |
+| iOS coroutine bridge | `co.touchlab.skie:gradle-plugin` (SKIE) |

+ 37 - 0
.claude/skills/mjdev-code-conventions/SKILL.md

@@ -0,0 +1,37 @@
+---
+name: mjdev-code-conventions
+description: Mandatory mjdev project coding conventions — apply when writing, reviewing, or refactoring ANY code in this repo (Kotlin, Compose, Gradle, build scripts). Covers no-hardcoding, one-component/one-class-per-file, and the enum vs companion-object rule for constants.
+---
+
+# mjdev project code conventions (non-negotiable)
+
+Apply these to every change in this repository. The user enforces them strictly.
+
+## 1. No hardcoding
+Never inline literal values that belong in configuration. Read them from the **version catalog**
+(`gradle/libs.versions.toml`) or the appropriate config, and thread that single source through
+build scripts, tasks, and shell helpers.
+- Versions, app metadata, package names, dependency lists, paths, URLs → catalog / config.
+- Example precedent: the wayland runtime dependency list lives once as
+  `app-compositor-runtime-deps` in the catalog and is consumed by the deb packaging task,
+  `installDesktop`, and `make-iso.sh` (which reads it via its `read_catalog` sed) — not copied.
+- Shell scripts that can't parse TOML read catalog keys with the existing `read_catalog` helper.
+- If a value truly has no config home, justify it; default is: put it in config.
+
+## 2. One Compose component per file
+Each `@Composable` UI component gets its own file named after it. No bundling multiple
+components into one file. Previews for that component may live in the same file.
+
+## 3. One class per file
+Each class/interface/object gets its own file named after it. No multi-class files.
+
+## 4. Constants: enum first, else companion object
+- Group related constants as an **`enum class`** (or sealed type) — preferred.
+- If an enum doesn't fit (e.g. unrelated scalar constants for one class), put them in a
+  **`companion object`** of the owning class. Never scatter loose top-level `const val`s or
+  magic literals through the code.
+
+## Scope note for this repo
+The user has said the **desktop app code is off-limits** during the ISO/compositor/packaging work —
+only `compositor/`, `session/`, and deb/ISO packaging are fair game there. These conventions still
+apply to whatever code you DO touch. Related: [[no-hardcode]] memory.

+ 48 - 0
.claude/skills/mjdev-package-dependencies/SKILL.md

@@ -0,0 +1,48 @@
+---
+name: mjdev-package-dependencies
+description: Multiplatform packaging rule for mjdev — every distributable that has runtime dependencies must declare them through that package format's native dependency mechanism (deb Depends, rpm Requires, …) so the OS package manager resolves them on install; if the format has no dependency system, bundle the dependencies inside the package. Use when building/reviewing deb/rpm/AppImage/exe/msi/dmg/flatpak/apk packaging so a clean install "just works" on any machine.
+---
+
+# Packaging: declare deps natively, else bundle them
+
+Goal: a user who **gets a package and installs it must be able to use the app on any machine** —
+the package pulls or carries everything it needs. Never ship a package that silently misses a
+runtime dependency (that is what black-screened fresh installs).
+
+## The rule
+For each distributable, put runtime dependencies where that format resolves them:
+
+| Format     | Dependency mechanism (preferred)                     | If not possible → bundle |
+|------------|------------------------------------------------------|--------------------------|
+| **.deb**   | `Depends:` (+ `Recommends:`) in `DEBIAN/control` → apt resolves | ship the lib in the package + ldconfig/rpath |
+| **.rpm**   | `Requires:` tags → dnf/zypper resolve                | ship the lib in the package |
+| **AppImage** | none — **bundle everything** in the AppDir (that's the point of AppImage) | (always bundle) |
+| **.exe/.msi** | installer prerequisites / bundled redistributables | bundle DLLs next to the exe |
+| **.dmg/.pkg** | macOS frameworks / `@rpath`                        | bundle dylibs in the .app |
+| **flatpak** | runtime + extensions in the manifest                | bundle in the sandbox |
+| **.apk**   | gradle deps compiled in (self-contained)             | (always self-contained) |
+
+Prefer the native dependency system (smaller package, shared/updated libs). Only **bundle** when
+the format has no resolver (AppImage) or the dep is unavailable / ABI-pinned on the target distro.
+
+## Single source of truth (no hardcode — see [[mjdev-code-conventions]])
+Keep the dependency list in **one place** (the version catalog: `app-compositor-runtime-deps`) and
+feed every packager + installer + ISO from it. Don't copy the list into each packaging step.
+
+## TWO product shapes — don't conflate them
+1. **Full desktop session** = deb / rpm / live ISO. Ships the compositor (`mjdevc`) + `mjdev-session`
+   + the `wayland-sessions` entry, and the app runs as the session **shell**. THESE need the wayland
+   runtime stack (libwlroots/xwayland/mesa/seatd) declared/bundled — a clean/console box has no
+   compositor. The desktop session is **Linux-only**.
+2. **Standalone app** = AppImage / exe / msi / dmg / apk. The app runs as a **normal window on the
+   user's EXISTING desktop** (GNOME/KDE/other wayland or X) — that environment already provides a
+   compositor. So **do NOT bundle mjdevc / wlroots here**; just ship the self-contained app (jpackage
+   already bundles the JRE + Skiko). On a real desktop GPU, Skiko uses hardware GL normally.
+
+## This project's reference
+- deb (shape 1): `PackageFullDebTask` (buildSrc) injects compositor + session and appends the wayland
+  runtime stack to `Depends` (from the catalog), so `apt install ./mjdev-desktop.deb` pulls
+  libwlroots/xwayland/mesa/seatd on a clean console Linux.
+- rpm (shape 1): same completeness via `Requires:` (rpm rebuild) — TODO.
+- AppImage/exe/dmg/apk (shape 2): leave as jpackage produces — self-contained app, **no compositor**.
+- `buildAll` (and the GitHub Actions release) must emit ALL formats — the more platforms the better.

+ 49 - 0
.claude/skills/mjdev-privilege-escalation/SKILL.md

@@ -0,0 +1,49 @@
+---
+name: mjdev-privilege-escalation
+description: How to run privileged (root) steps in this project on Linux — prefer a graphical pkexec dialog in a GUI session, fall back to sudo, run directly when already root (CI). Use when a task/script needs root (install, debootstrap/chroot, mount, dpkg/apt, writing under /usr) and must work both from the IDE/desktop and headless/CI without hanging on a password prompt.
+---
+
+# Privilege escalation (Linux) — pkexec in GUI, else sudo, else direct
+
+When a step needs root, pick the escalation method by environment — never assume an interactive
+terminal exists (the Gradle daemon has no TTY, so a bare `sudo` would hang).
+
+## Decision order
+1. **Already root** (CI, containers): run the command directly, no escalation.
+   `id -u == 0` or `USER == root`.
+2. **GUI session** (`DISPLAY` or `WAYLAND_DISPLAY` set) **and** `pkexec` available:
+   use **`pkexec`** → it shows a graphical polkit authentication dialog (needs no TTY, works from
+   a detached daemon). This is the preferred desktop path.
+3. **Otherwise**: fall back to **`sudo`** (works with passwordless/NOPASSWD sudo on CI runners).
+
+## Gotchas (learned)
+- `pkexec` **resets the environment and detaches stdio** — the child's stdout/stderr do NOT reach
+  the caller's console. If you need the output, have the privileged script `tee` to a log file
+  (e.g. `exec > >(tee /tmp/x.log) 2>&1`) and read that file afterwards.
+- `pkexec` hands work back as root: chown results back to the invoking user via `PKEXEC_UID`
+  (or `SUDO_UID` under sudo) so files aren't left root-owned in the user's tree.
+- A polkit **agent must be running** in the session for the dialog to appear (true on GNOME/KDE).
+- Reference implementations in this repo: `makeIso` and `installDesktop` (build.gradle.kts /
+  compositor.gradle.kts) choose root/pkexec/sudo exactly this way.
+
+## Kotlin/Gradle snippet (the pattern)
+```kotlin
+val args = mutableListOf<String>()
+val isRoot = System.getenv("USER") == "root" ||
+    runCatching { ProcessBuilder("id","-u").start().inputStream.bufferedReader().readText().trim() == "0" }.getOrDefault(false)
+when {
+    isRoot -> {}                                                            // direct
+    File("/usr/bin/pkexec").canExecute() && !System.getenv("DISPLAY").isNullOrBlank() -> args += "pkexec"
+    else -> args += "sudo"
+}
+args += listOf("/bin/bash", scriptPath, /* … */)
+ProcessBuilder(args).inheritIO().start().waitFor()
+```
+
+## Shell snippet
+```sh
+if [ "$(id -u)" -eq 0 ]; then SUDO=;
+elif command -v pkexec >/dev/null && { [ -n "$DISPLAY" ] || [ -n "$WAYLAND_DISPLAY" ]; }; then SUDO=pkexec;
+else SUDO=sudo; fi
+"$SUDO" sh -c '…privileged…'
+```

+ 85 - 0
.claude/skills/mjdev-qemu-test/SKILL.md

@@ -0,0 +1,85 @@
+---
+name: mjdev-qemu-test
+description: Boot and test the mjdev wayland desktop (the make-iso ISO or the installed deb) in QEMU headlessly — capture screenshots + session logs, diagnose why the session does not start, iterate live WITHOUT rebuilding the ISO, and reflect verified fixes back into the project (make-iso.sh / session / deb packaging). Use when asked to run/test the desktop ISO in QEMU, screenshot the booted desktop, or debug "black screen / session won't start / desktop not running" on the live image or after installDesktop.
+---
+
+# mjdev desktop — QEMU test & debug loop
+
+Goal: the **deb and the ISO must make the mjdev wayland desktop usable on any machine**.
+Iterate **without** rebuilding the ISO each time (a full `makeIso` is ~10 min). Only rebuild
+for a final check. Reflect every working change back into the project.
+
+## Architecture (what runs)
+- `mjdevc` = the wayland compositor (Kotlin/Native, links `libwlroots-0.18` + the wayland stack).
+- `mjdev-session` (`session/mjdev-session`) = the session launcher: sets env, `exec mjdevc --session --shell-cmd /opt/mjdev-desktop/bin/mjdev-desktop`, logs to `~/.cache/mjdev-desktop/session.log`.
+- shell = the Compose Desktop app (`/opt/mjdev-desktop`). It is **AWT-based → needs an X display**,
+  which `mjdevc` provides via its built-in **XWayland** (shim.c:1351, `setenv DISPLAY` at 1368-1374).
+  This is *not* xorg; "pure wayland" still needs XWayland for this one legacy app.
+- On tty1 the ISO autologins `mjdev` → `mjdev-session`. If it crashes, agetty relogs → crash-loop → black screen.
+
+## Known root causes (check these first)
+1. **`libwlroots-0.18.so: cannot open shared object file`** — the runtime wayland stack mjdevc
+   links is not installed. The jpackage deb does NOT pull it. FIX: install/depend on
+   `libwlroots-0.18 xwayland libegl1 libgles2 libgbm1 libinput10 libseat1 libxkbcommon0`
+   (libwlroots-0.18 pulls libdrm/libwayland/libdisplay-info/libliftoff/libpixman/... as its deps).
+2. **`Software rendering detected ... WLR_RENDERER_ALLOW_SOFTWARE`** — in a VM (no GPU) mesa is
+   llvmpipe and wlroots refuses software GL. FIX: `export WLR_RENDERER_ALLOW_SOFTWARE=1` in
+   `session/mjdev-session` (already there; ignored on real GPUs). Needs `libgl1-mesa-dri`.
+3. **`java.awt.HeadlessException: No X11 DISPLAY`** — only when the shell is run with no compositor
+   (e.g. a bare serial shell). Under mjdevc with XWayland+DISPLAY it is fine. Not a real bug by itself.
+
+Map mjdevc's deps to packages on the (Debian trixie) host:
+`ldd compositor/build/session-install/mjdevc | awk '/=>/{print $3}' | while read f; do dpkg -S "$(readlink -f "$f")"; done`
+
+## Always log the failure
+`mjdev-session` redirects mjdevc+shell to `~/.cache/mjdev-desktop/session.log` (and `.log.1` = previous
+crash). Without that the tty1 session dies to a black screen with no trace. Keep this.
+
+## Fast capture (screenshot + input) — `qmp_drive.py`
+Boot QEMU headless with a framebuffer + QMP + a USB tablet, then drive it:
+```sh
+ISO=releases/mjdev-desktop-1.0.3.iso
+# direct-kernel boot is faster than -boot d; extract once:
+xorriso -osirrox on -indev "$ISO" -extract /live/vmlinuz /tmp/iso-test/vmlinuz -extract /live/initrd.img /tmp/iso-test/initrd.img
+qemu-system-x86_64 -enable-kvm -cpu host -m 4096 -smp 4 \
+  -kernel /tmp/iso-test/vmlinuz -initrd /tmp/iso-test/initrd.img -append "boot=live console=ttyS0,115200" \
+  -cdrom "$ISO" -device virtio-vga -usb -device usb-tablet \
+  -qmp unix:/tmp/iso-test/qmp.sock,server,nowait -serial file:/tmp/iso-test/vm-serial.log -display none &
+python3 .claude/skills/mjdev-qemu-test/qmp_drive.py /tmp/iso-test/qmp.sock   # writes /tmp/iso-test/*.png
+```
+`qmp_drive.py` screendumps boot frames, then moves the pointer to the **bottom edge** (reveals the
+bottom bar) and the **right edge** (reveals the control center) and dumps each — then quits the VM.
+PPM→PNG is built in (no deps). Read the PNGs with the Read tool. `var=10` ≈ black; higher ≈ content.
+
+## Grab the session log over serial (when screenshot is black)
+The serial getty prompt timing is fragile — wait ~80s, send a wake newline, then login + cat:
+```sh
+{ sleep 80; printf '\n\nmjdev\n'; sleep 5; \
+  printf 'cat ~/.cache/mjdev-desktop/session.log.1; echo ---; cat ~/.cache/mjdev-desktop/session.log\n'; sleep 8; } \
+| timeout 130 qemu-system-x86_64 -enable-kvm -cpu host -m 4096 -smp 4 \
+  -kernel /tmp/iso-test/vmlinuz -initrd /tmp/iso-test/initrd.img -append "boot=live console=ttyS0,115200" \
+  -cdrom "$ISO" -nographic -serial mon:stdio 2>&1 | tr -d '\r' | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g'
+```
+
+## Iterate WITHOUT rebuilding the ISO
+Once an ISO with the runtime deps exists, debug session/compositor changes live:
+- Share the project into the guest: add `-virtfs local,path=$PWD,mount_tag=host,security_model=none,readonly=on`
+  and in the guest `mkdir -p /mnt/host && mount -t 9p -o trans=virtio host /mnt/host`.
+- `mjdev` is in group `video` → it can get a seatd seat and run the compositor **without root**.
+  Copy a freshly-built `mjdevc`/`mjdev-session` from `/mnt/host/...` into `~/bin`, point `MJDEVC` at it,
+  run it, capture via QMP. No rebuild.
+- New apt deps need root: a debug ISO can `passwd -d root` in provisioning so serial root login works.
+- Only `apt`/dep changes or kernel/initrd changes truly need a rebuild — almost never.
+
+## Reflect fixes back into the project (do this every time something works)
+- runtime deps → `make-iso.sh` provision (`apt-get install ...`) **and** the deb `Depends`
+  (deb packaging) so a clean console install pulls them via apt.
+- session/env fixes → `session/mjdev-session`.
+- compositor fixes → `compositor/native/shim.c` / `compositor/src/...` (do NOT touch the desktop app code).
+
+## House rules (from the user)
+- Don't touch the existing desktop **app** code — only compositor, session, deb packaging are fair game.
+- Pure wayland (no xorg); XWayland-shim for the AWT shell is acceptable.
+- Builds are heavy (mksquashfs xz pegs all cores) — if the user is in a meeting / wants quiet, kill
+  `qemu-system`, `mksquashfs` (root, via pkexec), and the gradle daemon.
+- `makeIso` escalates via pkexec; the script tees to `/tmp/mjdev-iso-build.log` (pkexec hides stdio).

+ 90 - 0
.claude/skills/mjdev-qemu-test/qmp_drive.py

@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+# Drive a running QEMU via its QMP unix socket to test the mjdev wayland desktop.
+# Screendumps boot frames, then reveals the bottom bar (pointer -> bottom edge) and the
+# control center (pointer -> right edge), dumping each, then quits the VM. PPM->PNG is
+# built in (no deps) so frames can be viewed with the Read tool.
+#
+#   python3 qmp_drive.py /tmp/iso-test/qmp.sock [/out/dir]
+# Requires the VM launched with: -qmp unix:<sock>,server,nowait  and a tablet:
+#   -usb -device usb-tablet   (absolute pointer; abs axes are 0..32767)
+import socket, json, sys, time, struct, zlib, os
+
+SOCK = sys.argv[1] if len(sys.argv) > 1 else "/tmp/iso-test/qmp.sock"
+OUT = sys.argv[2] if len(sys.argv) > 2 else "/tmp/iso-test"
+
+class QMP:
+    def __init__(self, path):
+        self.s = socket.socket(socket.AF_UNIX)
+        for _ in range(120):
+            try:
+                self.s.connect(path); break
+            except OSError:
+                time.sleep(1)
+        else:
+            raise SystemExit("no QMP socket: " + path)
+        self.f = self.s.makefile("rwb", buffering=0)
+        self._read(); self.cmd("qmp_capabilities")
+    def _read(self):
+        while True:
+            line = self.f.readline()
+            if not line: raise EOFError("QMP closed")
+            obj = json.loads(line)
+            if "event" in obj: continue
+            return obj
+    def cmd(self, exe, **a):
+        m = {"execute": exe}
+        if a: m["arguments"] = a
+        self.f.write((json.dumps(m) + "\n").encode()); return self._read()
+    def dump(self, p): self.cmd("screendump", filename=p)
+    def move(self, xf, yf):
+        self.cmd("input-send-event", events=[
+            {"type": "abs", "data": {"axis": "x", "value": int(xf*32767)}},
+            {"type": "abs", "data": {"axis": "y", "value": int(yf*32767)}}])
+
+def ppm2png(ppm, png):
+    d = open(ppm, "rb").read()
+    if d[:2] != b"P6": raise ValueError("not P6")
+    idx, vals = 2, []
+    while len(vals) < 3:
+        while d[idx] in b" \t\n\r": idx += 1
+        s = idx
+        while d[idx] not in b" \t\n\r": idx += 1
+        vals.append(int(d[s:idx]))
+    w, h, _ = vals; idx += 1
+    raw = d[idx:idx+w*h*3]
+    def ch(t, x): return struct.pack(">I", len(x)) + t + x + struct.pack(">I", zlib.crc32(t+x) & 0xffffffff)
+    rows = bytearray()
+    for y in range(h):
+        rows.append(0); rows += raw[y*w*3:(y+1)*w*3]
+    open(png, "wb").write(b"\x89PNG\r\n\x1a\n" + ch(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+                          + ch(b"IDAT", zlib.compress(bytes(rows), 6)) + ch(b"IEND", b""))
+    return w, h
+
+def variation(ppm):
+    d = open(ppm, "rb").read(); body = d[15:15+60000]
+    return (max(body) - min(body)) if body else 0
+
+def shot(q, name, t):
+    ppm, png = f"{OUT}/{name}.ppm", f"{OUT}/{name}.png"
+    q.dump(ppm)
+    try:
+        w, h = ppm2png(ppm, png)
+        print(f"[{t:>4}s] {name}.png {w}x{h} var={variation(ppm)}", flush=True)
+    except Exception as e:
+        print(f"[{t:>4}s] {name}: {e}", flush=True)
+
+def main():
+    os.makedirs(OUT, exist_ok=True)
+    q = QMP(SOCK); print(">> QMP connected", flush=True)
+    t0 = time.time(); el = lambda: int(time.time()-t0)
+    for t in (60, 100, 140, 180, 220):
+        while el() < t: time.sleep(1)
+        shot(q, f"boot-{t:03d}s", el())
+    q.move(0.5, 0.999); time.sleep(3); shot(q, "bottombar", el())
+    q.move(0.999, 0.5); time.sleep(3); shot(q, "controlcenter", el())
+    q.move(0.5, 0.5); time.sleep(2); shot(q, "final", el())
+    try: q.cmd("quit")
+    except Exception: pass
+    print(">> done", flush=True)
+
+main()

+ 67 - 0
.claude/skills/mjdev-qemu-test/test-desktop.sh

@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# Boots the mjdev live ISO in QEMU (headless, KVM) and verifies the wayland desktop
+# actually starts: captures the session log to ./log.txt and screenshots to /tmp/iso-test,
+# then prints PASS/FAIL by inspecting the log. No ISO rebuild — tests an existing ISO.
+#
+#   .claude/skills/mjdev-qemu-test/test-desktop.sh [ISO] [--shots]
+#     ISO       path to the live iso (default: releases/<app>-<ver>.iso)
+#     --shots   also capture screenshots via QMP (needs qmp_drive.py alongside)
+#
+# What it checks in ~/.cache/mjdev-desktop/session.log:
+#   PASS  -> "shell started" present AND no "RenderException"/"cannot open shared object"
+#   FAIL  -> prints the first error line (missing lib / renderer / Skiko GL context / ...)
+set -euo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+ROOT="$(cd "$HERE/../../.." && pwd)"        # project root (.claude/skills/<name>/ -> root)
+CATALOG="$ROOT/gradle/libs.versions.toml"
+rc(){ sed -n "s/^$1 *= *\"\(.*\)\"/\1/p" "$CATALOG"; }
+APP="$(rc app-name)"; VER="$(rc app-pkg-version)"
+
+ISO=""; SHOTS=0
+for a in "$@"; do case "$a" in --shots) SHOTS=1;; *) ISO="$a";; esac; done
+ISO="${ISO:-$ROOT/releases/$APP-$VER.iso}"
+[ -f "$ISO" ] || { echo "iso not found: $ISO (build with ./gradlew makeIso)"; exit 1; }
+WORK=/tmp/iso-test; mkdir -p "$WORK"
+command -v qemu-system-x86_64 >/dev/null || { echo "need qemu-system-x86_64"; exit 1; }
+
+echo ">> extracting kernel/initrd from $(basename "$ISO")"
+xorriso -osirrox on -indev "$ISO" \
+  -extract /live/vmlinuz "$WORK/vmlinuz" -extract /live/initrd.img "$WORK/initrd.img" 2>/dev/null
+
+KVM=(); [ -w /dev/kvm ] && KVM=(-enable-kvm -cpu host)
+
+# --- phase 1: serial login -> dump session.log to project-root log.txt ---
+echo ">> booting (serial) to capture session.log ..."
+{
+  sleep 30; printf '\n\nmjdev\n'; sleep 4
+  printf 'echo ===LOGSTART===; cat ~/.cache/mjdev-desktop/session.log.1 2>/dev/null; echo "[--- current ---]"; cat ~/.cache/mjdev-desktop/session.log 2>/dev/null; echo ===LOGEND===\n'
+  sleep 7
+} | timeout 80 qemu-system-x86_64 "${KVM[@]}" -m 4096 -smp 4 \
+  -kernel "$WORK/vmlinuz" -initrd "$WORK/initrd.img" -append "boot=live console=ttyS0,115200" \
+  -cdrom "$ISO" -nographic -serial mon:stdio > "$WORK/serial.raw" 2>&1 || true
+tr -d '\r' < "$WORK/serial.raw" | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g' \
+  | sed -n '/===LOGSTART===/,/===LOGEND===/p' > "$ROOT/log.txt"
+
+# --- phase 2 (optional): screenshots via QMP ---
+if [ "$SHOTS" = 1 ] && [ -f "$HERE/qmp_drive.py" ]; then
+  echo ">> booting (framebuffer) for screenshots ..."
+  rm -f "$WORK/qmp.sock"
+  qemu-system-x86_64 "${KVM[@]}" -m 4096 -smp 4 \
+    -kernel "$WORK/vmlinuz" -initrd "$WORK/initrd.img" -append "boot=live console=ttyS0,115200" \
+    -cdrom "$ISO" -device virtio-vga -usb -device usb-tablet \
+    -qmp "unix:$WORK/qmp.sock,server,nowait" -display none > "$WORK/qemu-shots.log" 2>&1 &
+  python3 "$HERE/qmp_drive.py" "$WORK/qmp.sock" || true
+fi
+
+# --- verdict ---
+echo "================ session.log -> log.txt ================"
+sed -n '1,40p' "$ROOT/log.txt"
+echo "========================================================"
+if grep -q "shell started" "$ROOT/log.txt" \
+   && ! grep -qiE "RenderException|cannot open shared object|failed to create wlr" "$ROOT/log.txt"; then
+  echo "RESULT: PASS — compositor + shell started, no fatal render/link error"
+else
+  echo "RESULT: FAIL — first error:"
+  grep -m1 -iE "RenderException|cannot open shared object|failed to create|EGL_BAD|error" "$ROOT/log.txt" || echo "  (no obvious error line; inspect log.txt)"
+fi
+[ "$SHOTS" = 1 ] && ls -la "$WORK"/*.png 2>/dev/null || true

+ 714 - 0
.claude/skills/modularization/SKILL.md

@@ -0,0 +1,714 @@
+---
+name: modularization
+description: Modularize Android/KMP projects — feature-based module graph, build-logic convention plugins, module boundary rules, build time optimization, and Gradle multi-module setup for KMP targets
+argument-hint: "<module to create or feature to extract>"
+user-invocable: true
+allowed-tools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"]
+---
+
+# Modularization — Android / KMP
+
+## Why Modularize
+
+- **Build speed**: only changed modules recompile
+- **Team scale**: teams own modules, parallel development without conflicts
+- **Enforced boundaries**: compiler prevents illegal cross-layer access
+- **Reuse**: `:core:design-system` shared across `:feature:study` and `:feature:words`
+- **Testability**: modules tested in isolation with fakes
+
+---
+
+## Target Module Graph
+
+```
+:app
+ ├── :feature:auth
+ ├── :feature:study
+ ├── :feature:words
+ ├── :feature:profile
+ └── :feature:import
+        ↓ (all features depend on)
+ :domain
+ :core:common
+ :core:network
+ :core:database
+ :core:design-system
+ :core:testing      (testImplementation only)
+ :platforms         (expect/actual for platform-specific APIs)
+ :resources         (shared strings, assets, MR)
+```
+
+### Dependency Direction Rules (STRICT)
+
+- `:feature:*` → `:domain`, `:core:*`, `:resources` ✅
+- `:feature:*` → another `:feature:*` ✗
+- `:domain` → nothing (pure Kotlin) ✅
+- `:core:design-system` → `:domain`, `:core:network`, `:core:database` ✗
+- `:app` → all modules (wires DI, navigation) ✅
+
+---
+
+## settings.gradle.kts (root)
+
+The `pluginManagement` block **must** come first so `build-logic` convention plugins resolve before any `include()`.
+
+```kotlin
+// settings.gradle.kts (root)
+pluginManagement {
+    includeBuild("build-logic")          // composite build — makes kmp.* plugins available
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+dependencyResolutionManagement {
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "MyApp"
+
+// App
+include(":app")
+
+// Feature modules
+include(":feature:auth")
+include(":feature:study")
+include(":feature:words")
+include(":feature:profile")
+include(":feature:import")
+
+// Domain
+include(":domain")
+
+// Core
+include(":core:common")
+include(":core:network")
+include(":core:database")
+include(":core:design-system")
+include(":core:testing")
+
+// Platform
+include(":platforms")
+include(":resources")
+```
+
+---
+
+## gradle/libs.versions.toml — plugin alias entries
+
+Convention plugin IDs must appear in the version catalog so modules can reference them via `alias(libs.plugins.*)`.
+
+```toml
+[plugins]
+# Convention plugins (version = "unspecified" because they come from the composite build)
+kmp-library  = { id = "kmp.library",  version = "unspecified" }
+kmp-feature  = { id = "kmp.feature",  version = "unspecified" }
+kmp-compose  = { id = "kmp.compose",  version = "unspecified" }
+android-library = { id = "android.library", version = "unspecified" }
+
+# External plugins
+kotlin-multiplatform    = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+android-application     = { id = "com.android.application",           version.ref = "agp" }
+compose-multiplatform   = { id = "org.jetbrains.compose",             version.ref = "compose-multiplatform" }
+kotlin-serialization    = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+sqldelight              = { id = "app.cash.sqldelight",                version.ref = "sqldelight" }
+dependency-guard        = { id = "com.dropbox.dependency-guard",       version.ref = "dependency-guard" }
+```
+
+---
+
+## build-logic Convention Plugins
+
+Eliminates boilerplate from every module's `build.gradle.kts`.
+
+### Plugin Directory Structure
+
+```
+build-logic/
+├── settings.gradle.kts
+└── convention/
+    ├── build.gradle.kts
+    └── src/main/kotlin/
+        ├── KmpLibraryConventionPlugin.kt
+        ├── KmpFeatureConventionPlugin.kt
+        ├── AndroidLibraryConventionPlugin.kt
+        └── ComposeConventionPlugin.kt
+```
+
+### build-logic/settings.gradle.kts
+
+```kotlin
+dependencyResolutionManagement {
+    repositories {
+        google()
+        mavenCentral()
+    }
+    versionCatalogs {
+        create("libs") { from(files("../gradle/libs.versions.toml")) }
+    }
+}
+rootProject.name = "build-logic"
+include(":convention")
+```
+
+### build-logic/convention/build.gradle.kts
+
+```kotlin
+plugins {
+    `kotlin-dsl`
+}
+
+dependencies {
+    compileOnly(libs.android.gradlePlugin)
+    compileOnly(libs.kotlin.gradlePlugin)
+    compileOnly(libs.compose.gradlePlugin)
+}
+
+gradlePlugin {
+    plugins {
+        register("kmpLibrary") {
+            id = "kmp.library"
+            implementationClass = "KmpLibraryConventionPlugin"
+        }
+        register("kmpFeature") {
+            id = "kmp.feature"
+            implementationClass = "KmpFeatureConventionPlugin"
+        }
+        register("androidLibrary") {
+            id = "android.library"
+            implementationClass = "AndroidLibraryConventionPlugin"
+        }
+        register("kmpCompose") {
+            id = "kmp.compose"
+            implementationClass = "ComposeConventionPlugin"
+        }
+    }
+}
+```
+
+### KmpLibraryConventionPlugin — shared KMP module setup
+
+Inside `Plugin<Project>`, the `kotlin {}` / `android {}` DSL shortcuts are not in scope.
+Use `extensions.configure<>` to access each extension safely.
+`applyDefaultHierarchyTemplate()` is required so that the `iosMain` intermediary source set
+exists when modules reference it (e.g. `iosMain.dependencies { }`).
+
+```kotlin
+// build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt
+import com.android.build.gradle.LibraryExtension
+import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
+
+class KmpLibraryConventionPlugin : Plugin<Project> {
+    override fun apply(target: Project) {
+        with(target) {
+            pluginManager.apply("org.jetbrains.kotlin.multiplatform")
+            pluginManager.apply("com.android.library")
+
+            extensions.configure<KotlinMultiplatformExtension> {
+                applyDefaultHierarchyTemplate()   // creates iosMain, nativeMain, etc.
+
+                androidTarget()
+                iosArm64()
+                iosSimulatorArm64()
+
+                // Single toolchain call replaces both compileOptions and kotlinOptions.jvmTarget
+                jvmToolchain(17)
+
+                sourceSets {
+                    commonMain.dependencies {
+                        implementation(libs.findLibrary("koin-core").get())
+                        implementation(libs.findLibrary("coroutines-core").get())
+                    }
+                    androidMain.dependencies {
+                        implementation(libs.findLibrary("koin-android").get())
+                    }
+                }
+            }
+
+            extensions.configure<LibraryExtension> {
+                compileSdk = 36
+                // namespace must be set per-module; derive it from the project path
+                namespace = "com.example${target.path.replace(':', '.').replace('-', '_')}"
+                defaultConfig { minSdk = 24 }
+            }
+        }
+    }
+}
+```
+
+### KmpFeatureConventionPlugin — feature module with Compose
+
+```kotlin
+// build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
+import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
+
+class KmpFeatureConventionPlugin : Plugin<Project> {
+    override fun apply(target: Project) {
+        with(target) {
+            pluginManager.apply("kmp.library")    // applies KmpLibraryConventionPlugin
+            pluginManager.apply("kmp.compose")    // applies ComposeConventionPlugin
+
+            // kmp.library has already applied the KMP plugin; configure the extension directly
+            extensions.configure<KotlinMultiplatformExtension> {
+                sourceSets {
+                    commonMain.dependencies {
+                        implementation(project(":domain"))
+                        implementation(project(":core:common"))
+                        implementation(project(":core:design-system"))
+                        implementation(project(":resources"))
+                    }
+                    commonTest.dependencies {
+                        implementation(project(":core:testing"))
+                    }
+                }
+            }
+        }
+    }
+}
+```
+
+---
+
+## App Module
+
+`:app` wires navigation, DI, and the Android entry point. It is the only module allowed to depend on all other modules.
+
+```kotlin
+// app/build.gradle.kts
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.kotlin.multiplatform)
+    alias(libs.plugins.kmp.compose)
+}
+
+kotlin {
+    androidTarget()
+
+    sourceSets {
+        androidMain.dependencies {
+            implementation(project(":feature:auth"))
+            implementation(project(":feature:study"))
+            implementation(project(":feature:words"))
+            implementation(project(":feature:profile"))
+            implementation(project(":feature:import"))
+            implementation(project(":domain"))
+            implementation(project(":core:common"))
+            implementation(project(":core:network"))
+            implementation(project(":core:database"))
+            implementation(project(":core:design-system"))
+            implementation(project(":resources"))
+            implementation(project(":platforms"))
+        }
+    }
+}
+
+android {
+    namespace = "com.example.app"
+    compileSdk = 36
+    defaultConfig {
+        applicationId = "com.example.app"
+        minSdk = 24
+        targetSdk = 36
+        versionCode = 1
+        versionName = "1.0"
+    }
+}
+```
+
+---
+
+## Feature Module build.gradle.kts (using convention)
+
+```kotlin
+// feature/study/build.gradle.kts
+plugins {
+    alias(libs.plugins.kmp.feature)   // applies KmpFeatureConventionPlugin — everything configured
+}
+
+kotlin {
+    sourceSets {
+        commonMain.dependencies {
+            // Only module-specific deps beyond the convention defaults
+            implementation(libs.some.extra.library)
+        }
+    }
+}
+```
+
+---
+
+## Domain Module (Pure Kotlin — No Android)
+
+```kotlin
+// domain/build.gradle.kts
+plugins {
+    alias(libs.plugins.kotlin.multiplatform)
+    // NO android plugin — domain is pure Kotlin
+}
+
+kotlin {
+    jvm()       // for unit tests on JVM
+    iosArm64()
+    iosSimulatorArm64()
+
+    jvmToolchain(17)
+
+    sourceSets {
+        commonMain.dependencies {
+            implementation(libs.coroutines.core)
+            implementation(libs.kotlinx.datetime)
+        }
+        commonTest.dependencies {
+            implementation(libs.kotlin.test)
+            implementation(libs.coroutines.test)
+        }
+    }
+}
+```
+
+---
+
+## Core Modules
+
+### core:common
+
+```kotlin
+// core/common/build.gradle.kts
+plugins { alias(libs.plugins.kmp.library) }
+
+kotlin {
+    sourceSets {
+        commonMain.dependencies {
+            api(libs.arrow.core)          // Try<T> — api so consumers get it
+            api(libs.coroutines.core)
+        }
+    }
+}
+```
+
+### core:network
+
+`iosMain` is available because `KmpLibraryConventionPlugin` calls `applyDefaultHierarchyTemplate()`.
+
+```kotlin
+// core/network/build.gradle.kts
+plugins { alias(libs.plugins.kmp.library) }
+
+kotlin {
+    sourceSets {
+        commonMain.dependencies {
+            implementation(project(":core:common"))
+            api(libs.ktor.client.core)
+            implementation(libs.ktor.client.content.negotiation)
+            implementation(libs.ktor.serialization.json)
+        }
+        androidMain.dependencies {
+            implementation(libs.ktor.client.okhttp)
+        }
+        iosMain.dependencies {                // exists because of applyDefaultHierarchyTemplate()
+            implementation(libs.ktor.client.darwin)
+        }
+    }
+}
+```
+
+### core:database
+
+```kotlin
+// core/database/build.gradle.kts
+plugins {
+    alias(libs.plugins.kmp.library)
+    alias(libs.plugins.sqldelight)
+}
+
+sqldelight {
+    databases {
+        create("AppDatabase") {
+            packageName.set("com.example.db")
+            verifyMigrations.set(true)
+        }
+    }
+}
+```
+
+### core:design-system
+
+Depends **only** on Compose Multiplatform. No `:domain`, no network, no database.
+
+```kotlin
+// core/design-system/build.gradle.kts
+plugins {
+    alias(libs.plugins.kmp.library)
+    alias(libs.plugins.kmp.compose)
+}
+
+kotlin {
+    sourceSets {
+        commonMain.dependencies {
+            // Compose multiplatform UI primitives only
+            implementation(compose.runtime)
+            implementation(compose.foundation)
+            implementation(compose.material3)
+            implementation(compose.ui)
+        }
+    }
+}
+```
+
+### core:testing
+
+```kotlin
+// core/testing/build.gradle.kts
+plugins { alias(libs.plugins.kmp.library) }
+
+kotlin {
+    sourceSets {
+        commonMain.dependencies {   // test helpers exposed as main (only used in test source sets)
+            implementation(project(":domain"))
+            implementation(libs.kotlin.test)
+            implementation(libs.coroutines.test)
+            implementation(libs.turbine)
+        }
+    }
+}
+```
+
+### platforms
+
+Houses `expect`/`actual` declarations for APIs that differ per platform (e.g., file system, UUID, clock).
+
+```
+platforms/
+└── src/
+    ├── commonMain/kotlin/platforms/
+    │   ├── FileSystem.kt       # expect fun readFile(path: String): String
+    │   └── Uuid.kt             # expect fun randomUuid(): String
+    ├── androidMain/kotlin/platforms/
+    │   ├── FileSystem.android.kt
+    │   └── Uuid.android.kt
+    └── iosMain/kotlin/platforms/
+        ├── FileSystem.ios.kt
+        └── Uuid.ios.kt
+```
+
+```kotlin
+// platforms/build.gradle.kts
+plugins { alias(libs.plugins.kmp.library) }
+// No extra dependencies — expect/actual only
+```
+
+### resources
+
+Centralises shared string resources using Moko Resources (or another MR library).
+
+```kotlin
+// resources/build.gradle.kts
+plugins {
+    alias(libs.plugins.kmp.library)
+    alias(libs.plugins.moko.resources)   // or multiplatform-resources
+}
+
+multiplatformResources {
+    resourcesPackage.set("com.example.resources")
+}
+```
+
+---
+
+## Feature Module Structure
+
+Each `:feature:X` follows identical internal layout:
+
+```
+feature/study/
+└── src/
+    └── commonMain/kotlin/feature/study/
+        ├── di/
+        │   └── StudyModule.kt          # Koin module
+        ├── domain/                     # Feature-specific use cases (if not in :domain)
+        │   └── GetDueWordsUseCase.kt
+        ├── presentation/
+        │   ├── StudyScreen.kt
+        │   ├── StudyViewModel.kt
+        │   └── StudyState.kt
+        └── navigation/
+            └── StudyNavigation.kt      # Route definitions + composable extensions
+```
+
+### Feature Navigation (NavGraph extension)
+
+```kotlin
+// feature/study/src/commonMain/kotlin/feature/study/navigation/StudyNavigation.kt
+@Serializable object StudyRoute
+
+fun NavGraphBuilder.studyGraph(onNavigateToWord: (Int) -> Unit) {
+    composable<StudyRoute> {
+        StudyScreen(onNavigateToWord = onNavigateToWord)
+    }
+}
+```
+
+Wire in `:app`:
+
+```kotlin
+// app/src/commonMain/kotlin/navigation/AppNavGraph.kt
+NavHost(navController, startDestination = StudyRoute) {
+    studyGraph(onNavigateToWord = { id -> navController.navigate(WordDetailRoute(id)) })
+    wordsGraph(onNavigateToStudy = { navController.navigate(StudyRoute) })
+    authGraph(onAuthSuccess = { navController.navigate(StudyRoute) { popUpTo(0) } })
+}
+```
+
+### Feature DI Module
+
+```kotlin
+// feature/study/src/commonMain/kotlin/feature/study/di/StudyModule.kt
+val studyModule = module {
+    viewModel { StudyViewModel(get(), get()) }
+    factory { GetDueWordsUseCase(get()) }
+}
+```
+
+Registered in `:app`:
+
+```kotlin
+// app/src/commonMain/kotlin/di/AppModule.kt
+startKoin {
+    modules(
+        domainModule,
+        networkModule,
+        databaseModule,
+        studyModule,
+        wordsModule,
+        authModule,
+    )
+}
+```
+
+---
+
+## Enforcing Module Boundaries
+
+### Dependency Guard (build-time enforcement)
+
+Check both release and debug classpaths to catch all illegal dependencies.
+
+```kotlin
+// build.gradle.kts (root)
+plugins {
+    alias(libs.plugins.dependency.guard)
+}
+
+dependencyGuard {
+    configuration("releaseRuntimeClasspath")
+    configuration("debugRuntimeClasspath")
+}
+```
+
+Run `./gradlew dependencyGuard` to generate baselines, then `./gradlew dependencyGuardBaseline` to update them after intentional changes.
+
+### Detekt Module Rule
+
+```yaml
+# detekt.yml
+complexity:
+  ForbiddenImport:
+    active: true
+    imports:
+      - value: 'androidx.room.*'
+        reason: 'Use domain repository interfaces, not Room directly in feature modules'
+      - value: 'io.ktor.*'
+        reason: 'Network access only via data layer interfaces'
+```
+
+### Validate via Gradle
+
+```bash
+# Check no illegal cross-feature dependency
+./gradlew :feature:study:dependencies | grep ':feature:'
+# Should only see :feature:study itself, never another :feature:*
+
+# Check domain has no Android imports
+./gradlew :domain:dependencies | grep 'androidx'
+# Should return nothing
+```
+
+---
+
+## Build Speed — Module Caching
+
+```properties
+# gradle.properties
+org.gradle.parallel=true
+org.gradle.caching=true
+org.gradle.configureondemand=true
+kotlin.incremental.multiplatform=true
+
+# Each module produces its own build cache entry.
+# A change in :feature:study does NOT recompile :feature:words.
+```
+
+---
+
+## Common Mistakes / Gotchas
+
+| Mistake | Fix |
+|---|---|
+| `pluginManagement { includeBuild("build-logic") }` missing | Must be the **first block** in root `settings.gradle.kts` before any `include()` |
+| `kotlin { }` / `android { }` used directly in `Plugin<Project>` | Use `extensions.configure<KotlinMultiplatformExtension>` / `extensions.configure<LibraryExtension>` |
+| `iosMain.dependencies { }` fails to resolve | Call `applyDefaultHierarchyTemplate()` in the convention plugin to create the intermediary source set |
+| Missing `namespace` in `LibraryExtension` | AGP 7.3+ requires `namespace`; set it per module, not in the convention plugin |
+| `compileOptions { sourceCompatibility = JavaVersion.VERSION_17 }` | Replace with `jvmToolchain(17)` — single call covers both Java and Kotlin toolchains |
+| Version catalog missing plugin aliases | Add `kmp-library`, `kmp-feature`, `kmp-compose` entries with `version = "unspecified"` |
+| `:core:design-system` importing `:domain` | Design system must depend only on Compose — no business types leak into UI primitives |
+| `:core:testing` on production classpath | Only ever use via `commonTest.dependencies { }` or `testImplementation` |
+
+---
+
+## Migrating a Monolith to Modules — Phased Plan
+
+**Phase 1 — Extract core (no feature changes)**
+1. Create `:core:common` with `Try<T>`, `BaseViewModel`, shared utilities
+2. Create `:core:network` with Ktor client
+3. Create `:core:database` with SQLDelight schema
+4. Update `:app` to depend on these
+
+**Phase 2 — Extract domain**
+1. Move all models, use case interfaces, repository interfaces to `:domain`
+2. Keep implementations in `:app` temporarily
+
+**Phase 3 — Extract features one at a time**
+1. Start with the most isolated feature (fewest cross-feature dependencies)
+2. Move presentation + feature DI module
+3. Move feature-specific use cases
+4. Wire navigation extension into `:app`
+
+**Phase 4 — Clean up `:app`**
+- `:app` should contain only: `MainActivity`, `KoinApplication`, `AppNavGraph`, manifest
+
+---
+
+## Module Checklist
+
+- [ ] `pluginManagement { includeBuild("build-logic") }` is the first block in root `settings.gradle.kts`
+- [ ] Convention plugin uses `extensions.configure<KotlinMultiplatformExtension>` — not bare `kotlin { }`
+- [ ] `applyDefaultHierarchyTemplate()` called in `KmpLibraryConventionPlugin`
+- [ ] `namespace` set for every Android module (AGP 8.x requirement)
+- [ ] `jvmToolchain(17)` used — not `compileOptions`
+- [ ] Version catalog has `kmp-library`, `kmp-feature`, `kmp-compose` plugin aliases
+- [ ] Convention plugin used — no boilerplate repeated in `build.gradle.kts`
+- [ ] `:domain` has zero Android/framework imports
+- [ ] `:feature:X` depends on `:domain` and `:core:*`, never on another `:feature:*`
+- [ ] `:core:design-system` depends on nothing except Compose multiplatform
+- [ ] `:platforms` contains only `expect`/`actual` declarations
+- [ ] Each feature has its own Koin module registered in `:app`
+- [ ] Navigation extension function in each feature, wired in `:app`
+- [ ] `:core:testing` only appears as `commonTest.dependencies { }` — never production classpath
+- [ ] `dependencyGuard` checks both `releaseRuntimeClasspath` and `debugRuntimeClasspath`
+- [ ] `org.gradle.parallel=true` and `org.gradle.caching=true` in `gradle.properties`
+- [ ] Build cache verified: changing `:feature:study` does not invalidate `:feature:words` cache

+ 805 - 0
.claude/skills/solid-android/SKILL.md

@@ -0,0 +1,805 @@
+---
+name: solid-android
+description: Apply SOLID, YAGNI, DRY, and KISS principles in Kotlin/Android — SRP, OCP, LSP, ISP, DIP, avoiding premature abstraction, identifying genuine duplication, and keeping code focused and minimal
+argument-hint: "<class, design problem, or code to evaluate>"
+user-invocable: true
+allowed-tools: ["Read", "Write", "Edit", "Glob", "Grep"]
+---
+
+# SOLID Principles in Kotlin / Android
+
+> **Project conventions used throughout this file:**
+> - `Try<T>` — project-specific sealed `Result` wrapper from `:core` (`Try.Success` / `Try.Failure`)
+> - `tryOf { }` — builder that catches exceptions and wraps them as `Try.Failure`
+> - `updateState { }`, `emitEffect()`, `reduce()` — ViewModel convention extensions from `:core:presentation`
+
+---
+
+## S — Single Responsibility Principle
+
+> A class should have only one reason to change.
+
+### Violation
+
+```kotlin
+// BAD — handles UI state, analytics, navigation, AND storage
+// Also: accessing `context` in a ViewModel is itself a DIP violation
+class WordViewModel : ViewModel() {
+    fun onWordReviewed(word: Word, correct: Boolean) {
+        // Update UI state
+        _state.update { it.copy(lastReviewed = word) }
+        // Send analytics — belongs in a service
+        FirebaseAnalytics.getInstance(context).logEvent("word_reviewed", bundleOf(
+            "word_id" to word.id,
+            "correct" to correct,
+        ))
+        // Navigate — belongs in an effect/event
+        navController.navigate("result")
+        // Save to prefs — belongs in a repository
+        prefs.edit().putLong("last_review", System.currentTimeMillis()).apply()
+    }
+}
+```
+
+### Fix — Split Responsibilities
+
+```kotlin
+// ViewModel — manages UI state and delegates everything else
+class WordViewModel(
+    private val reviewWord: ReviewWordUseCase,
+    private val analytics: IAnalyticsService,
+) : ViewModel() {
+    fun onWordReviewed(word: Word, correct: Boolean) {
+        viewModelScope.launch {
+            reviewWord(ReviewWordUseCase.Params(word, if (correct) 5 else 1))
+                .reduce(
+                    onSuccess = {
+                        updateState { copy(lastReviewed = word) }
+                        emitEffect(WordEffect.NavigateToResult)
+                        analytics.track(AnalyticsEvent.WordReviewed(word.id, correct))
+                    },
+                    onFailure = { e -> updateState { copy(error = e.message) } },
+                )
+        }
+    }
+}
+
+// Analytics service — manages tracking only
+class FirebaseAnalyticsService(private val firebase: FirebaseAnalytics) : IAnalyticsService {
+    override fun track(event: AnalyticsEvent) {
+        when (event) {
+            is AnalyticsEvent.WordReviewed -> firebase.logEvent("word_reviewed",
+                bundleOf("word_id" to event.wordId, "correct" to event.correct))
+        }
+    }
+}
+```
+
+### SRP Layer Split: DataSource vs Repository
+
+A common violation is collapsing remote + local concerns into one class:
+
+```kotlin
+// BAD — one class talks to both Room and Ktor
+class WordRepository(private val db: AppDatabase, private val api: WordApi) {
+    suspend fun sync() {
+        val remote = api.fetchWords()          // network concern
+        db.wordDao().insertAll(remote.toEntity()) // storage concern
+    }
+}
+
+// GOOD — each source has one job; Repository orchestrates
+class WordRemoteDataSource(private val api: WordApi) : IWordRemoteDataSource {
+    override suspend fun fetchWords(): Try<List<WordDto>> = tryOf { api.fetchWords() }
+}
+
+class WordLocalDataSource(private val dao: WordDao) : IWordLocalDataSource {
+    override suspend fun insertAll(words: List<WordEntity>): Try<Unit> = tryOf { dao.insertAll(words) }
+    override fun observeAll(): Flow<List<WordEntity>> = dao.observeAll()
+}
+
+class WordRepositoryImpl(
+    private val remote: IWordRemoteDataSource,
+    private val local: IWordLocalDataSource,
+) : IWordRepository {
+    override suspend fun syncWithRemote(): Try<Unit> = tryOf {
+        val words = remote.fetchWords().getOrThrow()
+        local.insertAll(words.map { it.toEntity() }).getOrThrow()
+    }
+}
+```
+
+---
+
+## O — Open/Closed Principle
+
+> Open for extension, closed for modification.
+
+### Violation
+
+```kotlin
+// BAD — every new notification type requires modifying this class
+class NotificationSender {
+    fun send(type: String, wordId: Int) {
+        when (type) {
+            "push" -> sendPushNotification(wordId)
+            "email" -> sendEmail(wordId)
+            // Adding "sms" requires modifying this class
+        }
+    }
+}
+```
+
+### Fix — Polymorphism / Strategy
+
+```kotlin
+// Interface — contract (closed for modification)
+interface INotificationChannel {
+    fun send(notification: ReviewReminder)
+}
+
+// Implementations — extend without touching base contract
+class PushNotificationChannel(private val fcm: FirebaseMessaging) : INotificationChannel {
+    override fun send(notification: ReviewReminder) { /* push logic */ }
+}
+
+class EmailNotificationChannel(private val client: EmailClient) : INotificationChannel {
+    override fun send(notification: ReviewReminder) { /* email logic */ }
+}
+
+// New channel — added without modifying any existing code
+class SmsNotificationChannel(private val sms: SmsClient) : INotificationChannel {
+    override fun send(notification: ReviewReminder) { /* sms logic */ }
+}
+
+// Orchestrator — open for extension via DI
+class NotificationService(
+    private val channels: List<INotificationChannel>,
+) {
+    fun sendAll(notification: ReviewReminder) = channels.forEach { it.send(notification) }
+}
+```
+
+### Sealed Interfaces for Exhaustive Extension
+
+Use `sealed` when the set of variants is **closed** (owned by you) and exhaustiveness is enforced at call sites:
+
+```kotlin
+sealed interface SyncResult {
+    data class Success(val count: Int) : SyncResult
+    data class Partial(val synced: Int, val failed: Int) : SyncResult
+    data object NoNetwork : SyncResult
+    data object UpToDate : SyncResult
+}
+
+// Adding a new variant forces an update at ALL when() call sites — compile-time safety
+fun handle(result: SyncResult) = when (result) {
+    is SyncResult.Success  -> showSuccess(result.count)
+    is SyncResult.Partial  -> showPartial(result.synced, result.failed)
+    SyncResult.NoNetwork   -> showOffline()
+    SyncResult.UpToDate    -> { /* nothing */ }
+}
+```
+
+> Use `sealed` (closed, exhaustive) for domain results. Use `interface` (open, extensible) for notification channels, formatters, strategies.
+
+---
+
+## L — Liskov Substitution Principle
+
+> Subtypes must be substitutable for their base types without altering program correctness.
+
+### Violation — Unexpected Throw
+
+```kotlin
+// BAD — CachingWordRepository breaks contract by throwing when offline
+class CachingWordRepository(
+    private val remote: IWordRepository,
+    private val cache: IWordLocalDataSource,
+) : IWordRepository {
+    override suspend fun syncWithRemote(): Try<Unit> {
+        if (!networkMonitor.isConnected) throw IllegalStateException("No network")
+        // ^ BREAKS LSP — callers of IWordRepository.syncWithRemote() don't expect exceptions
+        return remote.syncWithRemote()
+    }
+}
+```
+
+### Fix — Honor the Contract
+
+```kotlin
+class CachingWordRepository(
+    private val remote: IWordRepository,
+    private val cache: IWordLocalDataSource,
+    private val networkMonitor: INetworkMonitor,
+) : IWordRepository {
+    override suspend fun syncWithRemote(): Try<Unit> = tryOf {
+        if (!networkMonitor.isConnected) return Try.Failure(NoNetworkException())
+        remote.syncWithRemote().getOrThrow()
+    }
+    // Contract honored: always returns Try<Unit>, never throws
+}
+```
+
+### Violation — Silent No-Op Subtype
+
+```kotlin
+// BAD — ReadOnlyWordList claims to implement IWordWriter but silently discards writes.
+// Callers believe saves succeed; data is silently lost.
+class ReadOnlyWordList : IWordWriter {
+    override suspend fun save(word: Word): Try<Word> = Try.Success(word) // no-op — word not actually saved
+    override suspend fun delete(id: Int): Try<Unit> = Try.Success(Unit)  // no-op — word not actually deleted
+}
+```
+
+### Fix — Use a Narrower Interface
+
+```kotlin
+// If a type can only read, don't make it implement IWordWriter.
+// Redesign so the contract matches the capability.
+class ReadOnlyWordList(private val reader: IWordReader) // only depends on what it can honor
+```
+
+### LSP in Compose
+
+```kotlin
+// BAD — ignores the onClick lambda entirely; callers wiring up actions get silent failures
+@Composable
+fun SubmitButton(text: String, onClick: () -> Unit) {
+    Button(onClick = {}) {   // onClick contract broken — caller's lambda is discarded
+        Text(text)
+    }
+}
+
+// GOOD — honor the full contract; use enabled to model disabled state, not a no-op lambda
+@Composable
+fun AppButton(
+    text: String,
+    onClick: () -> Unit,
+    enabled: Boolean = true,
+    modifier: Modifier = Modifier,
+) {
+    Button(onClick = onClick, enabled = enabled, modifier = modifier) {
+        Text(text)
+    }
+}
+```
+
+---
+
+## I — Interface Segregation Principle
+
+> Clients should not be forced to depend on interfaces they don't use.
+
+### Violation — Fat Repository Interface
+
+```kotlin
+// BAD — one fat interface; most clients only need a small subset
+interface IWordRepository {
+    fun observeWords(): Flow<List<Word>>
+    suspend fun findById(id: Int): Try<Word>
+    suspend fun save(word: Word): Try<Word>
+    suspend fun delete(id: Int): Try<Unit>
+    suspend fun syncWithRemote(): Try<Unit>
+    suspend fun importFromCsv(uri: Uri): Try<Int>
+    suspend fun exportToCsv(): Try<Uri>
+    suspend fun getStats(): Try<WordStats>
+    suspend fun clearAll(): Try<Unit>
+}
+```
+
+### Fix — Role Interfaces
+
+```kotlin
+interface IWordReader {
+    fun observeWords(): Flow<List<Word>>
+    suspend fun findById(id: Int): Try<Word>
+}
+
+interface IWordWriter {
+    suspend fun save(word: Word): Try<Word>
+    suspend fun delete(id: Int): Try<Unit>
+}
+
+interface IWordSync {
+    suspend fun syncWithRemote(): Try<Unit>
+}
+
+interface IWordImportExport {
+    suspend fun importFromCsv(uri: Uri): Try<Int>
+    suspend fun exportToCsv(): Try<Uri>
+}
+
+// Implementation combines all roles
+class WordRepositoryImpl(...) : IWordReader, IWordWriter, IWordSync, IWordImportExport
+
+// Use cases depend only on what they need
+class GetDueWordsUseCase(private val reader: IWordReader)
+class SyncUseCase(private val sync: IWordSync)
+class ImportWordsUseCase(private val importer: IWordImportExport)
+```
+
+### ISP for DataSources
+
+Apply the same split at the data layer:
+
+```kotlin
+interface IWordLocalDataSource {
+    fun observeAll(): Flow<List<WordEntity>>
+    suspend fun findById(id: Int): Try<WordEntity>
+    suspend fun insertAll(words: List<WordEntity>): Try<Unit>
+    suspend fun deleteById(id: Int): Try<Unit>
+}
+
+interface IWordRemoteDataSource {
+    suspend fun fetchWords(): Try<List<WordDto>>
+    suspend fun postResult(wordId: Int, score: Int): Try<Unit>
+}
+
+// Sync use case only needs remote; offline-first reader only needs local
+class SyncUseCase(
+    private val remote: IWordRemoteDataSource,
+    private val local: IWordLocalDataSource,
+)
+```
+
+### ISP for ViewModel State
+
+Large feature screens often have a single monolithic state interface. Break it by UI concern:
+
+```kotlin
+// BAD — SettingsViewModel forced to implement analytics and appearance together
+interface ISettingsViewModel {
+    val notificationsEnabled: StateFlow<Boolean>
+    val selectedTheme: StateFlow<Theme>
+    val analyticsOptIn: StateFlow<Boolean>
+    fun onNotificationToggled(enabled: Boolean)
+    fun onThemeSelected(theme: Theme)
+    fun onAnalyticsToggled(enabled: Boolean)
+}
+
+// GOOD — composables consume only the slice they render
+interface INotificationSettings {
+    val notificationsEnabled: StateFlow<Boolean>
+    fun onNotificationToggled(enabled: Boolean)
+}
+
+interface IThemeSettings {
+    val selectedTheme: StateFlow<Theme>
+    fun onThemeSelected(theme: Theme)
+}
+
+// ViewModel satisfies all; composables depend on the slice
+class SettingsViewModel : INotificationSettings, IThemeSettings, ...
+
+@Composable
+fun NotificationSection(settings: INotificationSettings) { ... }
+
+@Composable
+fun ThemeSection(settings: IThemeSettings) { ... }
+```
+
+---
+
+## D — Dependency Inversion Principle
+
+> High-level modules should not depend on low-level modules. Both should depend on abstractions.
+
+### Violation
+
+```kotlin
+// BAD — ViewModel directly depends on concrete Room DAO
+class WordListViewModel(
+    private val dao: WordDao,   // concrete — Room-specific, untestable
+) : ViewModel() {
+    fun load() {
+        viewModelScope.launch {
+            val words = dao.getAllWords().map { it.toDomain() }
+            updateState { copy(words = words) }
+        }
+    }
+}
+```
+
+### Fix — Depend on Abstraction
+
+```kotlin
+// Domain interface — abstraction (in :domain, no framework imports)
+interface IWordReader {
+    fun observeWords(): Flow<List<Word>>
+}
+
+// ViewModel — depends on use case, not Room
+class WordListViewModel(
+    private val getWords: GetDueWordsUseCase,
+) : ViewModel()
+
+// Use case — depends on repository interface
+class GetDueWordsUseCase(
+    private val repository: IWordReader,    // depends on interface, not impl
+) : FlowUseCase<Unit, List<Word>>
+
+// Repository impl — Room is an implementation detail, hidden here
+class WordRepositoryImpl(
+    private val dao: WordDao,
+) : IWordReader, IWordWriter, IWordSync, IWordImportExport
+```
+
+### DIP in DI Modules (Koin)
+
+```kotlin
+val dataModule = module {
+    // Bind interface → implementation; call sites never import the impl class
+    single<IWordReader> { WordRepositoryImpl(get()) }
+    single<IWordWriter> { get<WordRepositoryImpl>() }   // same instance, different role
+    single<IWordLocalDataSource> { WordLocalDataSourceImpl(get()) }
+    single<IWordRemoteDataSource> { WordRemoteDataSourceImpl(get()) }
+    single<INetworkMonitor> { AndroidNetworkMonitor(androidContext()) }
+}
+```
+
+### DIP in DI Modules (Hilt)
+
+```kotlin
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class DataModule {
+    @Binds @Singleton
+    abstract fun bindWordReader(impl: WordRepositoryImpl): IWordReader
+
+    @Binds @Singleton
+    abstract fun bindWordWriter(impl: WordRepositoryImpl): IWordWriter
+
+    @Binds @Singleton
+    abstract fun bindNetworkMonitor(impl: AndroidNetworkMonitor): INetworkMonitor
+}
+```
+
+---
+
+## SOLID in Testing
+
+SOLID principles directly enable testability. The two most impactful:
+
+**DIP → fakes are possible.** If a ViewModel depends on `IWordReader`, you can inject `FakeWordReader` in tests. Without DIP there is nothing to swap.
+
+**ISP → fakes are small.** A fake for a role interface implements 2 methods, not 9:
+
+```kotlin
+// Small fake — only what GetDueWordsUseCase needs
+class FakeWordReader : IWordReader {
+    var words: List<Word> = emptyList()
+    var error: Throwable? = null
+
+    override fun observeWords(): Flow<List<Word>> =
+        if (error != null) flow { throw error!! } else flowOf(words)
+
+    override suspend fun findById(id: Int): Try<Word> =
+        words.find { it.id == id }?.let { Try.Success(it) } ?: Try.Failure(NotFoundException())
+}
+
+// If you had used the fat IWordRepository, FakeWordRepository would need 9 stub methods
+// — most of which throw UnsupportedOperationException and add noise to every test file.
+```
+
+**SRP → tests have one reason to fail.** A class with a single responsibility has a predictable test surface; failures point directly at the broken concern.
+
+---
+
+## SOLID Checklist
+
+| Principle | Ask yourself |
+|---|---|
+| **SRP** | Can you name this class with a single-noun role? Would a change to analytics/storage/navigation force a change here? |
+| **OCP** | Can you add new behavior (new channel, new result type) without touching existing classes? |
+| **LSP** | Does every implementation honor the full contract — same preconditions, same postconditions, no extra throws? |
+| **ISP** | Does each client import only the methods it uses? Could you split this interface by role? |
+| **DIP** | Do ViewModel and UseCase import only interfaces? Is `import androidx.room.*` or `import io.ktor.*` absent from `:domain`? |
+
+---
+
+## Anti-Patterns to Avoid
+
+- **God ViewModel** — one ViewModel with 20+ methods driving an entire feature (SRP violation)
+- **God Activity / Fragment** — business logic, navigation, and analytics crammed into `onCreate` (SRP violation)
+- **`Context` or `android.*` in a UseCase** — the domain layer must be pure Kotlin (DIP violation)
+- **Concrete dependencies in domain** — `import androidx.room.*` or `import io.ktor.*` in a use case (DIP violation)
+- **Repository calling another repository** — orchestration belongs in a UseCase, not the data layer (SRP violation)
+- **Checking `instanceof` / `is Type`** — use polymorphism or sealed types instead (OCP violation)
+- **Throwing from overrides when the base contract doesn't throw** — breaks substitutability (LSP violation)
+- **Silent no-op overrides** — implementing an interface method as a no-op to satisfy the compiler (LSP violation)
+- **`IEverything` interface** — one interface covering an entire repository or screen (ISP violation)
+
+---
+
+## YAGNI — You Aren't Gonna Need It
+
+Don't build for hypothetical future requirements. Build for what is needed now.
+
+### Violations
+
+```kotlin
+// BAD — "flexible" config nobody asked for
+class WordRepositoryImpl(
+    private val local: IWordLocalDataSource,
+    private val remote: IWordRemoteDataSource,
+    private val strategy: SyncStrategy = SyncStrategy.DEFAULT,   // YAGNI — only DEFAULT exists
+    private val retryPolicy: RetryPolicy = RetryPolicy.NONE,     // YAGNI — never configured
+    private val cacheExpiry: Duration = 1.hours,                 // YAGNI — never read
+    private val logger: ILogger? = null,                         // YAGNI — logging already in place
+) : IWordRepository
+```
+
+```kotlin
+// BAD — base class for one implementation
+abstract class BaseNetworkDataSource {
+    abstract val baseUrl: String
+    abstract fun authenticate(request: HttpRequest): HttpRequest
+    open fun handleError(e: Throwable): Nothing = throw e
+    open fun transformResponse(response: HttpResponse): HttpResponse = response
+}
+
+class WordRemoteDataSourceImpl : BaseNetworkDataSource() {
+    // Only one concrete class ever exists — abstraction was never needed
+}
+```
+
+```kotlin
+// BAD — interface exists purely for DI, tests use the concrete class anyway
+interface IAnalyticsTracker {
+    fun track(event: String)
+}
+class FirebaseAnalyticsTracker : IAnalyticsTracker { ... }
+// Only one impl, tests just pass a no-op lambda — interface adds nothing
+
+// GOOD — use the concrete class; extract interface only when a fake is needed in tests
+class FirebaseAnalyticsTracker {
+    fun track(event: String) { ... }
+}
+```
+
+```kotlin
+// BAD — passthrough UseCase with zero business logic
+class GetWordsUseCase(private val repo: IWordRepository) {
+    operator fun invoke(): Flow<List<Word>> = repo.getWords()
+}
+// GOOD — call repo.getWords() directly from ViewModel
+// Add a UseCase only when it encodes real business logic (filtering, mapping, combining sources)
+```
+
+### YAGNI in KMP — expect/actual
+
+Only use `expect/actual` when platform behaviour genuinely differs. If both actuals do the same thing, use a common API instead.
+
+```kotlin
+// BAD — expect/actual for identical behaviour
+expect fun currentTimeMillis(): Long
+actual fun currentTimeMillis(): Long = System.currentTimeMillis()              // Android
+actual fun currentTimeMillis(): Long = kotlin.system.getTimeNanos() / 1_000_000  // iOS
+
+// GOOD — one line, no platform split needed
+val now = Clock.System.now().toEpochMilliseconds()
+```
+
+### YAGNI in Tests
+
+```kotlin
+// BAD — abstract BaseViewModelTest "all VMs will eventually need"
+abstract class BaseViewModelTest {
+    val testDispatcher = UnconfinedTestDispatcher()
+    @BeforeEach fun setup() { Dispatchers.setMain(testDispatcher) }
+    @AfterEach fun teardown() { Dispatchers.resetMain() }
+}
+// Only one ViewModel test class exists — just inline the setup there.
+
+// GOOD — add the base class when 3+ test classes independently duplicate the same setup
+```
+
+### Fix
+
+```kotlin
+// GOOD — build exactly what's needed, nothing more
+class WordRepositoryImpl(
+    private val local: IWordLocalDataSource,
+    private val remote: IWordRemoteDataSource,
+) : IWordRepository
+```
+
+### YAGNI Checklist
+
+- Is there a concrete, current requirement for this parameter/method/class?
+- Are there at least two real callers, or is it "in case we need it"?
+- Would removing it break anything real today?
+
+If "no" to all three — delete it.
+
+---
+
+## DRY — Don't Repeat Yourself
+
+> "Every piece of knowledge must have a single, unambiguous, authoritative representation within a system."
+
+DRY is about **knowledge**, not code. Three similar-looking lines that represent different concepts are NOT duplication. Extract only when the same **decision** appears multiple times.
+
+### Real Duplication — Extract
+
+```kotlin
+// BAD — same date formatting decision in three places
+// WordListScreen.kt
+val dateText = word.nextReviewDate.format(LocalDate.Format {
+    dayOfMonth(); chars(" "); monthName(MonthNames.ENGLISH_ABBREVIATED); chars(" "); year()
+})
+// WordDetailScreen.kt — same
+// NotificationBuilder.kt — same
+
+// GOOD — one place encodes the formatting decision
+fun LocalDate.toDisplayString(): String = format(LocalDate.Format {
+    dayOfMonth(); chars(" "); monthName(MonthNames.ENGLISH_ABBREVIATED); chars(" "); year()
+})
+```
+
+```kotlin
+// BAD — same SRS calculation in ViewModel, UseCase, and Service
+// GOOD — single SpacedRepetitionService owns the algorithm
+class SpacedRepetitionService {
+    fun calculateInterval(bucket: Int, quality: Int): Int = when {
+        quality < 2 -> 1
+        bucket == 0 -> 1
+        bucket == 1 -> 3
+        else        -> (bucket * 2.5).roundToInt()
+    }
+}
+```
+
+### Coincidental Duplication — Do NOT Extract
+
+```kotlin
+// These look the same but represent different business decisions.
+// Extracting them creates false coupling.
+fun UserDto.toDomain(): User   = User(id = id, name = name, email = email)
+fun AuthorDto.toDomain(): Author = Author(id = id, name = name, bio = bio)
+
+// If User gains a "role" field, Author must NOT — they are different concepts.
+// A shared base class would be wrong.
+```
+
+### DRY in Compose — Extract vs Inline
+
+```kotlin
+// Extract when: same UI + same behavior used in 2+ unrelated screens
+@Composable
+fun LoadingIndicator(modifier: Modifier = Modifier) {
+    Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+        CircularProgressIndicator()
+    }
+}
+
+// Do NOT extract when: similar-looking but different semantics
+// WordListScreen loading ≠ AuthScreen loading
+// They may diverge (size, color, copy) — keep them inline
+```
+
+### DRY in Compose — Theme Tokens
+
+```kotlin
+// BAD — same color decision hardcoded in multiple composables
+Text(color = Color(0xFF6200EE))
+Icon(tint = Color(0xFF6200EE))
+
+// GOOD — MaterialTheme is the single source of truth
+Text(color = MaterialTheme.colorScheme.primary)
+Icon(tint = MaterialTheme.colorScheme.primary)
+```
+
+---
+
+## KISS — Keep It Simple
+
+> Complexity is the enemy of reliability. The best code is the code that doesn't exist.
+
+### Avoid Premature Abstraction
+
+```kotlin
+// BAD — abstract factory for one product
+interface IWordParserFactory {
+    fun create(format: String): IWordParser
+}
+class WordParserFactoryImpl : IWordParserFactory {
+    override fun create(format: String): IWordParser = when (format) {
+        "csv" -> CsvWordParser()
+        else  -> throw IllegalArgumentException("Unknown format: $format")
+    }
+}
+// CsvWordParser is the only parser that ever exists.
+
+// GOOD
+fun parseCsvWords(csv: String): List<WordDto> = csv.lines()
+    .drop(1)  // header
+    .filter { it.isNotBlank() }
+    .map { line ->
+        val parts = line.split(",")
+        WordDto(original = parts[0].trim(), translated = parts[1].trim())
+    }
+```
+
+### Flat Data Class vs Sealed Hierarchy
+
+```kotlin
+// Use a flat data class when states can overlap
+// (e.g., showing stale data while loading, or error + retry button)
+data class WordListState(
+    val words: List<Word> = emptyList(),
+    val isLoading: Boolean = true,
+    val error: String? = null,
+) {
+    val isEmpty: Boolean get() = !isLoading && words.isEmpty() && error == null
+}
+
+// Use sealed when states are truly mutually exclusive
+// (e.g., multi-step wizard, auth flow, onboarding steps)
+sealed interface OnboardingState {
+    data object Welcome : OnboardingState
+    data class SetGoal(val availableGoals: List<Goal>) : OnboardingState
+    data object Complete : OnboardingState
+}
+// Sealed is wrong when you need "loading + previous data visible at the same time"
+// — that requires two independent fields, not one sealed branch.
+```
+
+### Simple Data Flows
+
+```kotlin
+// BAD — event bus for local ViewModel→Screen communication
+EventBus.post(WordDeletedEvent(wordId))
+
+// BAD — complex state machine
+enum class SyncState { IDLE, SYNCING, SUCCESS, FAILED, RETRYING }
+
+// GOOD — direct method call (event sink)
+viewModel.deleteWord(word)
+
+// GOOD — simple boolean flag in state
+data class SyncState(
+    val isSyncing: Boolean = false,
+    val lastSyncError: String? = null,
+)
+```
+
+### Inline Logic vs Premature Abstraction
+
+```kotlin
+// BAD — generic transformer written "for future use", called exactly once
+fun <T, R> Try<T>.flatMapWithLogging(tag: String, block: (T) -> Try<R>): Try<R> {
+    Log.d(tag, "flatMapping")
+    return flatMap(block)
+}
+
+// BAD — utility wrapper for something done in one place
+fun <T> List<T>.toImmutableListSafe(): ImmutableList<T> = this.toImmutableList()
+
+// GOOD — just write what you need where you need it
+val words = rawWords.toImmutableList()
+```
+
+---
+
+## Recognizing the Right Abstraction
+
+| Signal | Action |
+|---|---|
+| Same logic copied ≥3 times | Extract to shared function/class |
+| Same concept represented in 2 different ways | Canonicalize to one representation |
+| "We might need X later" | Don't build X — add it when needed |
+| Adding a parameter nobody currently uses | Remove it |
+| A class with only one caller | Inline the class |
+| An interface with only one impl AND tests don't need a fake | Remove the interface |
+| A UseCase that only delegates with no logic | Inline it into the ViewModel |
+| An `expect/actual` where both actuals are identical | Replace with a common-source API |
+
+## When Abstraction IS Right
+
+- Interface has 2+ implementations that currently exist
+- Interface is needed for testing (enables fakes)
+- Shared logic that encodes a genuine shared business rule
+- The abstraction reduces cognitive load for readers
+- The abstraction has a name that exists in the domain vocabulary

+ 574 - 0
.claude/skills/tdd-android/SKILL.md

@@ -0,0 +1,574 @@
+---
+name: tdd-android
+description: Test-Driven Development for Android/KMP — Red-Green-Refactor, ViewModel tests with Turbine, use case tests, repository tests with fakes, MockEngine for DataSource, and JUnit5 setup
+argument-hint: "<class or feature to test-drive>"
+user-invocable: true
+allowed-tools: ["Read", "Write", "Edit", "Glob", "Grep"]
+---
+
+# TDD — Android / KMP
+
+## Red → Green → Refactor
+
+1. **Red** — Write a failing test for the behavior you want
+2. **Green** — Write the minimum code to make it pass
+3. **Refactor** — Clean up without breaking tests
+
+Never write production code before a failing test exists.
+
+---
+
+## Test Pyramid
+
+```
+         ┌─────────────────┐
+         │    UI Tests      │  Slowest — Compose UI, Screenshot
+         ├─────────────────┤
+         │ Integration Tests│  Real DB, real network (MockWebServer)
+         ├─────────────────┤
+         │   Unit Tests     │  Fastest — VM, UseCase, Repository, DataSource
+         └─────────────────┘
+```
+
+**Focus**: 70% unit, 20% integration, 10% UI.
+
+---
+
+## JUnit5 Setup (Android)
+
+```kotlin
+// build.gradle.kts
+dependencies {
+    testImplementation(libs.junit5.api)
+    testImplementation(libs.junit5.params)
+    testRuntimeOnly(libs.junit5.engine)
+    testImplementation(libs.kotlinx.coroutines.test)
+    testImplementation(libs.turbine)
+    // avoid mockk — write fakes instead
+}
+
+tasks.withType<Test> {
+    useJUnitPlatform()
+}
+```
+
+```toml
+# libs.versions.toml
+junit5 = "5.11.3"
+turbine = "1.2.0"
+coroutines-test = "1.9.0"
+
+[libraries]
+junit5-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5" }
+junit5-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit5" }
+junit5-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
+coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-test" }
+```
+
+---
+
+## ViewModel Tests with Turbine
+
+### MainDispatcherRule
+
+`TestWatcher` and `@get:Rule` are JUnit4 APIs — do not use them with JUnit5. Use
+`BeforeEachCallback`/`AfterEachCallback` and `@RegisterExtension` instead.
+
+```kotlin
+// commonTest or androidTest
+class MainDispatcherRule(
+    val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
+) : BeforeEachCallback, AfterEachCallback {
+    override fun beforeEach(context: ExtensionContext) { Dispatchers.setMain(dispatcher) }
+    override fun afterEach(context: ExtensionContext) { Dispatchers.resetMain() }
+}
+```
+
+### ViewModel Test Pattern
+
+```kotlin
+// No @ExtendWith needed — we use fakes, not mocks
+class WordListViewModelTest {
+
+    @RegisterExtension
+    val mainDispatcherRule = MainDispatcherRule()
+
+    // Always use fakes over mocks for repositories
+    private val fakeRepo = FakeWordRepository()
+    private val getWords = GetDueWordsUseCase(fakeRepo)
+    private val deleteWord = DeleteWordUseCase(fakeRepo)
+    private lateinit var vm: WordListViewModel
+
+    @BeforeEach
+    fun setUp() {
+        vm = WordListViewModel(getWords, deleteWord)
+    }
+
+    @Test
+    fun `initial state is loading`() = runTest {
+        vm.state.test {
+            val state = awaitItem()
+            assertTrue(state.isLoading)
+            cancelAndIgnoreRemainingEvents()
+        }
+    }
+
+    @Test
+    fun `words loaded successfully updates state`() = runTest {
+        val words = listOf(testWord(id = 1), testWord(id = 2))
+
+        vm.state.test {
+            skipItems(1) // skip initial loading state
+
+            fakeRepo.emitWords(words)
+            val loaded = awaitItem()
+
+            assertFalse(loaded.isLoading)
+            assertEquals(2, loaded.words.size)
+            assertNull(loaded.error)
+
+            cancelAndIgnoreRemainingEvents()
+        }
+    }
+
+    @Test
+    fun `delete word emits undo effect`() = runTest {
+        val word = testWord()
+        fakeRepo.emitWords(listOf(word))
+
+        vm.effects.test {
+            vm.deleteWord(word)
+            val effect = awaitItem()
+            assertIs<WordListEffect.ShowUndo>(effect)
+            assertEquals(word, (effect as WordListEffect.ShowUndo).word)
+        }
+    }
+
+    @Test
+    fun `delete word failure updates error state`() = runTest {
+        val word = testWord()
+        fakeRepo.setDeleteError(RuntimeException("DB error"))
+
+        vm.state.test {
+            skipItems(1) // skip initial loading state
+
+            vm.deleteWord(word)
+            val errorState = awaitItem()
+
+            assertNotNull(errorState.error)
+            cancelAndIgnoreRemainingEvents()
+        }
+    }
+}
+```
+
+### Turbine: `cancelAndIgnoreRemainingEvents` vs `cancelAndConsumeRemainingEvents`
+
+- `cancelAndIgnoreRemainingEvents()` — cancels the flow and silently drops any unread items. Use
+  when you only care about specific items and the rest are irrelevant.
+- `cancelAndConsumeRemainingEvents()` — cancels and returns the remaining events as a list. Use
+  when you want to assert that no unexpected events arrived, or inspect what was left.
+
+### Testing Debounce / Time-Based Logic
+
+Use `advanceTimeBy` or `advanceUntilIdle` from `TestCoroutineScheduler` when the ViewModel
+uses `delay`, `debounce`, or retry with backoff:
+
+```kotlin
+@Test
+fun `search is debounced by 300ms`() = runTest {
+    vm.state.test {
+        skipItems(1) // initial
+
+        vm.onSearchQueryChanged("h")
+        vm.onSearchQueryChanged("he")
+        vm.onSearchQueryChanged("hel")
+
+        // No emission yet — debounce window not elapsed
+        expectNoEvents()
+
+        advanceTimeBy(300)
+
+        val searched = awaitItem()
+        assertEquals("hel", searched.query)
+        cancelAndIgnoreRemainingEvents()
+    }
+}
+```
+
+---
+
+## Try<T> — Custom Result Wrapper
+
+`tryOf {}` is a project-level inline helper that wraps a suspending block in `Try<T>`:
+
+```kotlin
+// domain/util/Try.kt
+sealed class Try<out T> {
+    data class Success<T>(val value: T) : Try<T>()
+    data class Failure<T>(val error: Throwable) : Try<T>()
+}
+
+inline fun <T> tryOf(block: () -> T): Try<T> = try {
+    Try.Success(block())
+} catch (e: Throwable) {
+    Try.Failure(e)
+}
+```
+
+All `suspend` repository methods return `Try<T>` — they never throw.
+
+---
+
+## Fake Repository Pattern
+
+```kotlin
+// test/fakes/FakeWordRepository.kt
+class FakeWordRepository : IWordRepository {
+
+    private val _words = MutableStateFlow<List<Word>>(emptyList())
+    private var deleteError: Throwable? = null
+    private var saveError: Throwable? = null
+
+    // Test helpers
+    fun emitWords(words: List<Word>) { _words.value = words }
+    fun setDeleteError(e: Throwable) { deleteError = e }
+    fun setSaveError(e: Throwable) { saveError = e }
+
+    // Interface implementation
+    override fun observeWords(): Flow<List<Word>> = _words.asStateFlow()
+
+    override suspend fun findById(id: Int): Try<Word> = tryOf {
+        _words.value.find { it.id == id } ?: error("Word $id not found")
+    }
+
+    override suspend fun save(word: Word): Try<Word> = tryOf {
+        saveError?.let { throw it }
+        val updated = _words.value.toMutableList().also { list ->
+            val idx = list.indexOfFirst { it.id == word.id }
+            if (idx >= 0) list[idx] = word else list.add(word)
+        }
+        _words.value = updated
+        word
+    }
+
+    override suspend fun delete(id: Int): Try<Unit> = tryOf {
+        deleteError?.let { throw it }
+        _words.value = _words.value.filter { it.id != id }
+    }
+
+    override suspend fun syncWithRemote(): Try<Unit> = Try.Success(Unit)
+}
+```
+
+---
+
+## Use Case Tests
+
+Each test constructs its own state — avoid `@BeforeEach` seeds that some tests must undo.
+
+```kotlin
+class ReviewWordUseCaseTest {
+
+    private val fakeRepo = FakeWordRepository()
+    private val srsService = SpacedRepetitionService()
+    private val useCase = ReviewWordUseCase(fakeRepo, srsService)
+
+    @Test
+    fun `correct review advances bucket and sets future review date`() = runTest {
+        val word = testWord(id = 1, bucket = 2)
+        fakeRepo.emitWords(listOf(word))
+
+        val result = useCase(ReviewWordUseCase.Params(word, quality = 5))
+
+        assertIs<Try.Success<Word>>(result)
+        assertEquals(3, result.value.bucket)
+        assertTrue(result.value.nextReviewDate > FIXED_DATE)
+    }
+
+    @Test
+    fun `incorrect review resets bucket to 0 and sets tomorrow`() = runTest {
+        val word = testWord(id = 1, bucket = 5)
+        fakeRepo.emitWords(listOf(word))
+
+        val result = useCase(ReviewWordUseCase.Params(word, quality = 1))
+
+        assertIs<Try.Success<Word>>(result)
+        assertEquals(0, result.value.bucket)
+        assertEquals(FIXED_DATE.plus(1, DateTimeUnit.DAY), result.value.nextReviewDate)
+    }
+
+    @Test
+    fun `repository failure propagates as Try Failure`() = runTest {
+        val word = testWord()
+        fakeRepo.emitWords(listOf(word))
+        fakeRepo.setSaveError(RuntimeException("DB locked"))
+
+        val result = useCase(ReviewWordUseCase.Params(word, quality = 5))
+
+        assertIs<Try.Failure<Word>>(result)
+    }
+}
+```
+
+### Nested Tests for Grouping
+
+Use `@Nested` to group related scenarios — keeps test output readable:
+
+```kotlin
+class WordListViewModelTest {
+
+    @RegisterExtension val mainDispatcherRule = MainDispatcherRule()
+
+    @Nested inner class `given empty repository` {
+        @Test fun `state shows empty list`() = runTest { ... }
+    }
+
+    @Nested inner class `given words loaded` {
+        @Test fun `state shows word count`() = runTest { ... }
+        @Test fun `delete emits undo effect`() = runTest { ... }
+    }
+
+    @Nested inner class `given repository error` {
+        @Test fun `state shows error message`() = runTest { ... }
+    }
+}
+```
+
+---
+
+## DataSource Tests with MockEngine (Ktor)
+
+```kotlin
+class WordRemoteDataSourceTest {
+
+    @Test
+    fun `fetchAll returns mapped words on 200`() = runTest {
+        val mockEngine = MockEngine { request ->
+            assertEquals("/api/words", request.url.encodedPath)
+            respond(
+                content = ByteReadChannel("""[{"id":1,"original":"hello","translated":"hola"}]"""),
+                status = HttpStatusCode.OK,
+                headers = headersOf(HttpHeaders.ContentType, "application/json"),
+            )
+        }
+        val client = createHttpClient(mockEngine)
+        val dataSource = WordRemoteDataSourceImpl(client)
+
+        val result = dataSource.fetchAll()
+
+        assertIs<Try.Success<List<WordDto>>>(result)
+        assertEquals(1, result.value.size)
+        assertEquals("hello", result.value[0].original)
+    }
+
+    @Test
+    fun `fetchAll returns failure on 401`() = runTest {
+        val mockEngine = MockEngine {
+            respond(content = ByteReadChannel(""), status = HttpStatusCode.Unauthorized)
+        }
+        val client = createHttpClient(mockEngine)
+        val dataSource = WordRemoteDataSourceImpl(client)
+
+        val result = dataSource.fetchAll()
+
+        assertIs<Try.Failure<List<WordDto>>>(result)
+    }
+
+    @Test
+    fun `fetchAll returns failure on network error`() = runTest {
+        val mockEngine = MockEngine { throw IOException("No route to host") }
+        val client = createHttpClient(mockEngine)
+        val dataSource = WordRemoteDataSourceImpl(client)
+
+        val result = dataSource.fetchAll()
+
+        assertIs<Try.Failure<List<WordDto>>>(result)
+    }
+}
+```
+
+---
+
+## Repository Tests (Fake DataSources)
+
+Fake DataSources mirror the Fake Repository pattern — expose test helpers, implement the interface.
+
+```kotlin
+// test/fakes/FakeWordLocalDataSource.kt
+class FakeWordLocalDataSource : IWordLocalDataSource {
+
+    private val _entities = MutableStateFlow<List<WordEntity>>(emptyList())
+    val savedEntities: List<WordEntity> get() = _entities.value
+
+    fun emit(entities: List<WordEntity>) { _entities.value = entities }
+
+    override fun observeAll(): Flow<List<WordEntity>> = _entities.asStateFlow()
+    override suspend fun replaceAll(entities: List<WordEntity>) { _entities.value = entities }
+    override suspend fun deleteById(id: Int) {
+        _entities.value = _entities.value.filter { it.id != id }
+    }
+}
+
+// test/fakes/FakeWordRemoteDataSource.kt
+class FakeWordRemoteDataSource : IWordRemoteDataSource {
+
+    private var words: List<WordDto> = emptyList()
+    private var fetchError: Throwable? = null
+
+    fun setWords(words: List<WordDto>) { this.words = words }
+    fun setFetchError(e: Throwable) { fetchError = e }
+
+    override suspend fun fetchAll(): Try<List<WordDto>> = tryOf {
+        fetchError?.let { throw it }
+        words
+    }
+}
+```
+
+```kotlin
+class WordRepositoryTest {
+
+    private val fakeLocal = FakeWordLocalDataSource()
+    private val fakeRemote = FakeWordRemoteDataSource()
+    private val repo = WordRepositoryImpl(fakeLocal, fakeRemote)
+
+    @Test
+    fun `observeWords maps entities to domain`() = runTest {
+        val entity = wordEntity(id = 1, original = "hello")
+        fakeLocal.emit(listOf(entity))
+
+        repo.observeWords().test {
+            val words = awaitItem()
+            assertEquals(1, words.size)
+            assertEquals("hello", words[0].original)
+            cancelAndIgnoreRemainingEvents()
+        }
+    }
+
+    @Test
+    fun `syncWithRemote replaces local data`() = runTest {
+        fakeRemote.setWords(listOf(wordDto(id = 1), wordDto(id = 2)))
+
+        val result = repo.syncWithRemote()
+
+        assertIs<Try.Success<Unit>>(result)
+        assertEquals(2, fakeLocal.savedEntities.size)
+    }
+
+    @Test
+    fun `syncWithRemote returns failure when remote fetch fails`() = runTest {
+        fakeRemote.setFetchError(IOException("timeout"))
+
+        val result = repo.syncWithRemote()
+
+        assertIs<Try.Failure<Unit>>(result)
+        assertEquals(0, fakeLocal.savedEntities.size) // local data untouched
+    }
+}
+```
+
+---
+
+## Parameterized Tests (JUnit5)
+
+```kotlin
+class SpacedRepetitionServiceTest {
+
+    private val service = SpacedRepetitionService()
+
+    @ParameterizedTest
+    @CsvSource(
+        "0, 5, 1",   // bucket=0, quality=5 → interval=1 day
+        "1, 5, 3",   // bucket=1, quality=5 → interval=3 days
+        "2, 5, 5",   // bucket=2, quality=5 → interval=5 days
+        "5, 1, 1",   // quality<2 → reset to 1 day
+    )
+    fun `calculateNextReview returns correct interval`(
+        bucket: Int,
+        quality: Int,
+        expectedDays: Long,
+    ) {
+        val word = testWord(bucket = bucket)
+        val result = service.calculateNextReview(word, quality)
+        assertEquals(FIXED_DATE.plus(expectedDays, DateTimeUnit.DAY), result)
+    }
+}
+```
+
+---
+
+## Test Builders / Factories
+
+Use a **fixed date** — never `LocalDate.now()` or `Clock.System.now()` in builders. Tests that
+depend on the current date are fragile and can fail at midnight.
+
+```kotlin
+// test/builders/TestBuilders.kt — shared across all test modules
+
+val FIXED_DATE: LocalDate = LocalDate(2025, 1, 1)
+val FIXED_INSTANT: Instant = Instant.parse("2025-01-01T00:00:00Z")
+
+fun testWord(
+    id: Int = 1,
+    original: String = "hello",
+    translated: String = "hola",
+    bucket: Int = 0,
+    nextReviewDate: LocalDate = FIXED_DATE,
+    createdAt: Instant = FIXED_INSTANT,
+) = Word(id, original, translated, bucket, nextReviewDate, createdAt)
+
+fun wordEntity(
+    id: Int = 1,
+    original: String = "hello",
+    translated: String = "hola",
+    srsLevel: Int = 0,
+    nextReview: String = FIXED_DATE.toString(),
+    createdAt: String = FIXED_INSTANT.toString(),
+) = WordEntity(id, original, translated, srsLevel, nextReview, createdAt)
+
+fun wordDto(
+    id: Int = 1,
+    original: String = "hello",
+    translated: String = "hola",
+) = WordDto(id, original, translated)
+```
+
+---
+
+## Dispatcher Injection
+
+Never hardcode `Dispatchers.IO` in production code — inject it so tests can replace it:
+
+```kotlin
+// Production
+class WordRepositoryImpl(
+    private val local: IWordLocalDataSource,
+    private val remote: IWordRemoteDataSource,
+    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
+) : IWordRepository {
+    override suspend fun syncWithRemote(): Try<Unit> = withContext(ioDispatcher) { ... }
+}
+
+// In tests — use UnconfinedTestDispatcher (already set via MainDispatcherRule)
+private val repo = WordRepositoryImpl(fakeLocal, fakeRemote, UnconfinedTestDispatcher())
+```
+
+---
+
+## TDD Checklist
+
+- [ ] Write the test BEFORE writing production code
+- [ ] Test name describes behavior: `` `given X when Y then Z` ``
+- [ ] One assertion concept per test
+- [ ] Use fakes, not mocks — fakes produce real behavior
+- [ ] Tests run fast (<100ms each) — no real network, no real disk I/O
+- [ ] Test the contract, not the implementation
+- [ ] All new ViewModels and UseCases have tests
+- [ ] Cover happy path + failure + edge cases
+- [ ] Parameterized tests for data-driven scenarios
+- [ ] `MainDispatcherRule` uses JUnit5 `@RegisterExtension`, not JUnit4 `@get:Rule`
+- [ ] No `LocalDate.now()` / `Clock.System.now()` in test builders — use fixed constants
+- [ ] Dispatchers injected, not hardcoded — replaceable in tests
+- [ ] `@Nested` used to group happy / failure / edge scenarios

+ 9 - 1
.github/workflows/release.yml

@@ -61,8 +61,16 @@ jobs:
         shell: bash
         continue-on-error: true
         run: |
-          sudo apt-get update && sudo apt-get install -y rpm || echo "::warning::rpm tools missing — .rpm may be skipped"
+          sudo apt-get update
+          sudo apt-get install -y rpm || echo "::warning::rpm tools missing — .rpm may be skipped"
+          # ISO toolchain (debootstrap/chroot/mksquashfs/grub+mtools) so buildAll's makeIso can
+          # assemble the bootable live image; missing tools would just skip the ISO.
+          sudo apt-get install -y debootstrap squashfs-tools xorriso mtools dpkg-dev \
+            grub-common grub-pc-bin grub-efi-amd64-bin debian-archive-keyring \
+            || echo "::warning::iso toolchain missing — .iso may be skipped"
           ./gradlew buildAll -PdepCheck=false --stacktrace || echo "::warning::linux/android buildAll failed — skipped"
+          # makeIso is part of buildAll; surface whether the .iso was actually produced
+          ls -la releases/*.iso 2>/dev/null && echo "ISO produced" || echo "::warning::no .iso in releases/ — makeIso skipped or failed"
 
       # ---------- windows (.exe) ----------
       - name: Build windows .exe

+ 3 - 0
.gitignore

@@ -63,3 +63,6 @@ composeApp/src/commonMain/resources/keys/*.key
 
 ### releases
 /releases/
+
+### iso images (never commit — GitHub rejects git blobs >100 MB; ship as a release asset)
+*.iso

+ 31 - 0
.run/makeIso.run.xml

@@ -0,0 +1,31 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="makeIso" type="GradleRunConfiguration" factoryName="Gradle">
+    <ExternalSystemSettings>
+      <option name="executionName" />
+      <option name="externalProjectPath" value="$PROJECT_DIR$" />
+      <option name="externalSystemIdString" value="GRADLE" />
+      <option name="scriptParameters" value="" />
+      <option name="taskDescriptions">
+        <list />
+      </option>
+      <option name="taskNames">
+        <list>
+          <option value="makeIso" />
+        </list>
+      </option>
+      <option name="vmOptions" />
+    </ExternalSystemSettings>
+    <ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
+    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+    <ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
+    <EXTENSION ID="com.android.tools.idea.testartifacts.testsuite.GradleRunConfigurationExtension">
+      <com.android.tools.idea.testartifacts.testsuite.SHOW_TEST_RESULT_IN_ANDROID_TEST_SUITE_VIEW>false</com.android.tools.idea.testartifacts.testsuite.SHOW_TEST_RESULT_IN_ANDROID_TEST_SUITE_VIEW>
+      <android.execution.deploysToLocalDevice>false</android.execution.deploysToLocalDevice>
+    </EXTENSION>
+    <DebugAllEnabled>false</DebugAllEnabled>
+    <RunAsTest>false</RunAsTest>
+    <GradleProfilingDisabled>false</GradleProfilingDisabled>
+    <GradleCoverageDisabled>false</GradleCoverageDisabled>
+    <method v="2" />
+  </configuration>
+</component>

+ 31 - 0
.run/runIsoQemu.run.xml

@@ -0,0 +1,31 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="runIsoQemu" type="GradleRunConfiguration" factoryName="Gradle">
+    <ExternalSystemSettings>
+      <option name="executionName" />
+      <option name="externalProjectPath" value="$PROJECT_DIR$" />
+      <option name="externalSystemIdString" value="GRADLE" />
+      <option name="scriptParameters" value="" />
+      <option name="taskDescriptions">
+        <list />
+      </option>
+      <option name="taskNames">
+        <list>
+          <option value="runIsoQemu" />
+        </list>
+      </option>
+      <option name="vmOptions" />
+    </ExternalSystemSettings>
+    <ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
+    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+    <ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
+    <EXTENSION ID="com.android.tools.idea.testartifacts.testsuite.GradleRunConfigurationExtension">
+      <com.android.tools.idea.testartifacts.testsuite.SHOW_TEST_RESULT_IN_ANDROID_TEST_SUITE_VIEW>false</com.android.tools.idea.testartifacts.testsuite.SHOW_TEST_RESULT_IN_ANDROID_TEST_SUITE_VIEW>
+      <android.execution.deploysToLocalDevice>false</android.execution.deploysToLocalDevice>
+    </EXTENSION>
+    <DebugAllEnabled>false</DebugAllEnabled>
+    <RunAsTest>false</RunAsTest>
+    <GradleProfilingDisabled>false</GradleProfilingDisabled>
+    <GradleCoverageDisabled>false</GradleCoverageDisabled>
+    <method v="2" />
+  </configuration>
+</component>

+ 6 - 4
ai-todo.txt

@@ -8,15 +8,17 @@
 * update dependencies from report in reports folder, on every updated library check
   that builds run and or ommit/revert update if impossible
 
-- bottom desktop panel have non valid x coordinate, bad computation when in nested,
-  when it should be shown, by moving mouse pointer down to screen, and or it have bad height,
+- bottom desktop panel have  invalid x coordinate, bad computation when in nested,
+  when it should be shown, by moving mouse pointer down to screen, and or, it have bad height,
   but usualy is shown out of nested window, may be nested window position is not starting 0,0 and
   that breaks computation?
 
 - handles that are on sliding windows 12dp please, there is 9dp i think yet.
 
-- in log when building theres a lot of warnings and errors probably, i want to have
-  clean log  if possible
+- in log, when building, there is a lot of warnings and errors, i want to have
+  clean log  if possible.
+
+  - iso se negeneruje do releases na githubu
 
 # unimportant changes if possible:
 # control center, when shown should hide menu and bottom bar if it is visible

+ 81 - 1
build.gradle.kts

@@ -160,6 +160,22 @@ val packageAppImageFile = tasks.register<PackageAppImageTask>("packageAppImageFi
     toolPath.set(appImageToolPath)
 }
 
+// Turns the jpackage desktop .deb into a *complete, self-installable* package: injects the
+// compositor + session + wayland-sessions entry and declares the wayland runtime stack in
+// Depends, so `apt install ./mjdev-desktop.deb` on a clean console Linux pulls libwlroots/
+// xwayland/mesa/... and the desktop actually starts. Overwrites the deb in place so every
+// downstream consumer (collectReleases, makeIso, installDesktop) uses the full deb.
+// runtime wayland stack from the version catalog (single source of truth — not hardcoded here).
+val compositorRuntimeDeps = libs.versions.app.compositor.runtime.deps.get().trim().split(Regex("\\s+"))
+val packageFullDeb = tasks.register<PackageFullDebTask>("packageFullDeb") {
+    group = "mjdev"
+    description = "Repacks the desktop .deb with the compositor + session + wayland runtime Depends (self-installable)."
+    dependsOn(":desktopApp:packageReleaseDeb", ":compositor:stageSession")
+    debDir.set(pkgDirV.resolve("deb").absolutePath)
+    sessionDir.set(rootDir.resolve("compositor/build/session-install").absolutePath)
+    runtimeDepends.set(compositorRuntimeDeps)
+}
+
 // Copies all distributables into releases/ with version in the filename
 // (mjdev-desktop-<version>.<ext>). Copy = configuration-cache safe.
 val collectReleases = tasks.register<Copy>("collectReleases") {
@@ -169,6 +185,7 @@ val collectReleases = tasks.register<Copy>("collectReleases") {
         ":desktopApp:packageReleaseDistributionForCurrentOS",
         ":androidApp:assembleRelease",
         packageAppImageFile,
+        packageFullDeb,
     )
     // local copies so the rename closures capture plain Strings (configuration-cache safe)
     val base = "$appNameV-$versionV"
@@ -182,10 +199,73 @@ val collectReleases = tasks.register<Copy>("collectReleases") {
     }
 }
 
+// Builds the bootable live ISO (releases/<app>-<ver>.iso) from the freshly built
+// desktop deb + compositor/session + the theme debs in deb-packages/, via make-iso.sh.
+// debootstrap/chroot/mksquashfs need root: when the build is already root (CI) we run
+// the script directly; otherwise pkexec shows a graphical password dialog (sudo cannot
+// prompt without a TTY). If the iso toolchain isn't installed the task logs a skip and
+// returns 0, so it never breaks buildAll on a plain dev machine.
+val makeIso = tasks.register("makeIso") {
+    group = "mjdev"
+    description = "Builds the minimal bootable mjdev desktop live ISO into releases/ (needs root / pkexec)."
+    dependsOn(":desktopApp:packageReleaseDeb", ":compositor:stageSession", packageFullDeb)
+    // capture plain Strings at configuration time — the doLast closure must not reference
+    // script-level vals (that captures the script instance, which is null under the
+    // configuration cache -> "Cannot invoke getDebDirV() because this$0 is null").
+    val isoOutPath = rootDir.resolve("releases/$appNameV-$versionV.iso").absolutePath
+    val makeIsoScriptPath = rootDir.resolve("make-iso.sh").absolutePath
+    val debDirPath = pkgDirV.resolve("deb").absolutePath
+    val sessionStagePath = rootDir.resolve("compositor/build/session-install").absolutePath
+    val extraDebsPath = rootDir.resolve("deb-packages").absolutePath
+    doLast {
+        val isoTools = listOf("debootstrap", "mksquashfs", "xorriso", "grub-mkrescue")
+        // debootstrap/grub-mkrescue live in /usr/sbin — which is often absent from the Gradle
+        // daemon's PATH (notably on CI), so search the sbin dirs too or makeIso wrongly skips.
+        val toolDirs = (System.getenv("PATH").orEmpty().split(File.pathSeparator) +
+            listOf("/usr/sbin", "/sbin", "/usr/local/sbin")).filter { it.isNotEmpty() }
+        val missing = isoTools.filter { tool -> toolDirs.none { dir -> File(dir, tool).canExecute() } }
+        if (missing.isNotEmpty()) {
+            logger.warn("::warning::makeIso: skipping ISO — missing tools: ${missing.joinToString(" ")} " +
+                "(apt install debootstrap squashfs-tools xorriso grub-common grub-pc-bin grub-efi-amd64-bin)")
+            return@doLast
+        }
+        val deb = File(debDirPath).listFiles { f -> f.extension == "deb" }?.firstOrNull()
+            ?: error("desktop .deb not found in $debDirPath — run :desktopApp:packageReleaseDeb")
+        val args = mutableListOf<String>()
+        val isRoot = (System.getenv("USER") == "root") ||
+            runCatching { ProcessBuilder("id", "-u").start().inputStream.bufferedReader().readText().trim() == "0" }.getOrDefault(false)
+        when {
+            isRoot -> {}
+            File("/usr/bin/pkexec").canExecute() && !System.getenv("DISPLAY").isNullOrBlank() -> args += "pkexec"
+            else -> args += "sudo"
+        }
+        args += listOf("/bin/bash", makeIsoScriptPath,
+            "--deb", deb.absolutePath,
+            "--compositor-bin", File(sessionStagePath, "mjdevc").absolutePath,
+            "--session-dir", sessionStagePath,
+            "--extra-debs", extraDebsPath,
+            "--out", isoOutPath)
+        logger.lifecycle("makeIso: ${args.joinToString(" ")}")
+        val code = ProcessBuilder(args).inheritIO().start().waitFor()
+        check(code == 0) { "make-iso.sh failed (exit $code)" }
+    }
+}
+
+// Boots the built ISO in QEMU (no root needed).
+tasks.register("runIsoQemu") {
+    group = "mjdev"
+    description = "Boots the mjdev desktop ISO in QEMU."
+    val script = rootDir.resolve("run-iso-qemu.sh")
+    doLast {
+        val code = ProcessBuilder("/bin/bash", script.absolutePath).inheritIO().start().waitFor()
+        check(code == 0) { "run-iso-qemu.sh failed (exit $code)" }
+    }
+}
+
 val buildAll = tasks.register("buildAll") {
     group = "mjdev"
     description = "Builds all distributables this host can produce, collects them into releases/ (stable names), and generates reports into reports/ — like every build."
-    dependsOn(collectReleases, postBuildCodeCheck)
+    dependsOn(collectReleases, makeIso, postBuildCodeCheck)
 }
 
 // Attach iOS framework build only when an iOS target actually exists in composeApp

+ 127 - 0
buildSrc/src/main/kotlin/PackageFullDebTask.kt

@@ -0,0 +1,127 @@
+import org.gradle.api.DefaultTask
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.TaskAction
+import java.io.File
+import java.security.MessageDigest
+
+/**
+ * Turns the jpackage desktop `.deb` (which only carries the Compose app + its Java/X deps) into a
+ * *complete, self-installable* mjdev-desktop package, in place:
+ *
+ *  - injects the wayland compositor (`mjdevc`), the session launcher (`mjdev-session`) and the
+ *    `wayland-sessions` entry, so one deb is the whole desktop, not just the app;
+ *  - rewrites `Depends`: strips the ubuntu-only `libjpeg-turbo8` (absent on Debian; Skiko bundles
+ *    its codecs) and appends the wayland runtime stack, so `apt install ./mjdev-desktop.deb` on a
+ *    clean console Linux pulls libwlroots/xwayland/mesa/… and the desktop actually starts (plain
+ *    `dpkg -i` can't resolve deps — that is why fresh installs black-screened);
+ *  - adds `Recommends: seatd`, a postinst (drop the stale X11 session, enable seatd, ldconfig),
+ *    regenerates `md5sums`, and rebuilds root:root.
+ *
+ * jpackage's control ends with a trailing blank line, so fields are edited mid-stanza (appending
+ * at EOF would start a second stanza -> dpkg "multiple package info entries"). Mirrors the style of
+ * [PackageAppImageTask]; config-cache safe (only String/list inputs, no project references). If
+ * `dpkg-deb` is unavailable the task logs and skips — it never fails the build.
+ */
+abstract class PackageFullDebTask : DefaultTask() {
+    @get:Input abstract val debDir: Property<String>        // dir holding the jpackage .deb (overwritten in place)
+    @get:Input abstract val sessionDir: Property<String>    // stageSession output: mjdevc + mjdev-session + mjdev.desktop
+    @get:Input abstract val runtimeDepends: ListProperty<String> // wayland runtime stack to add to Depends
+
+    @TaskAction
+    fun build() {
+        val dpkgDeb = which("dpkg-deb")
+        if (dpkgDeb == null) {
+            logger.lifecycle("[full-deb] dpkg-deb not found on PATH — skipping (deb keeps jpackage deps only). apt install dpkg-dev")
+            return
+        }
+        val dir = File(debDir.get())
+        val deb = dir.listFiles { f -> f.extension == "deb" }?.firstOrNull()
+        if (deb == null) { logger.lifecycle("[full-deb] no .deb in $dir — skipping"); return }
+        val sess = File(sessionDir.get())
+        val mjdevc = File(sess, "mjdevc")
+        check(mjdevc.canExecute()) { "[full-deb] mjdevc not found in $sess (run :compositor:stageSession)" }
+
+        val work = File.createTempFile("mjdev-fulldeb", "").apply { delete(); mkdirs() }
+        try {
+            run(dpkgDeb, "-R", deb.absolutePath, work.absolutePath)
+
+            // the whole desktop in one package
+            mjdevc.copyExec(File(work, "usr/bin/mjdevc"))
+            File(sess, "mjdev-session").copyExec(File(work, "usr/bin/mjdev-session"))
+            File(sess, "mjdev.desktop").copyInto(File(work, "usr/share/wayland-sessions/mjdev.desktop"), 420)
+
+            rewriteControl(File(work, "DEBIAN/control"))
+            writePostinst(File(work, "DEBIAN/postinst"))
+            regenMd5sums(work)
+
+            val tmpOut = File(dir, deb.name + ".full")
+            run(dpkgDeb, "--root-owner-group", "--build", "-Zxz", work.absolutePath, tmpOut.absolutePath)
+            check(tmpOut.exists() && deb.delete() && tmpOut.renameTo(deb)) { "[full-deb] could not overwrite ${deb.name}" }
+            logger.lifecycle("[full-deb] ${deb.name} now carries the compositor + wayland Depends (self-installable)")
+        } finally {
+            work.deleteRecursively()
+        }
+    }
+
+    /** Strip libjpeg-turbo8, append the runtime stack to Depends (mid-stanza), add Recommends: seatd. */
+    private fun rewriteControl(ctrl: File) {
+        val deps = runtimeDepends.get()
+        if (deps.isEmpty()) return
+        val lines = ctrl.readLines().toMutableList()
+        var dependsIdx = lines.indexOfFirst { it.startsWith("Depends:") }
+        if (dependsIdx >= 0) {
+            val existing = lines[dependsIdx].removePrefix("Depends:")
+                .split(",").map { it.trim() }.filter { it.isNotEmpty() && it != "libjpeg-turbo8" }
+            lines[dependsIdx] = "Depends: " + (existing + deps).joinToString(", ")
+        } else {
+            val pkgIdx = lines.indexOfFirst { it.startsWith("Package:") }.coerceAtLeast(0)
+            lines.add(pkgIdx + 1, "Depends: " + deps.joinToString(", "))
+            dependsIdx = pkgIdx + 1
+        }
+        if (lines.none { it.startsWith("Recommends:") }) lines.add(dependsIdx + 1, "Recommends: seatd")
+        ctrl.writeText(lines.joinToString("\n") + "\n")
+    }
+
+    private fun writePostinst(postinst: File) {
+        postinst.writeText(
+            """
+            #!/bin/sh
+            set -e
+            if [ "${'$'}1" = configure ]; then
+                rm -f /usr/share/xsessions/mjdev-desktop.desktop \
+                      /usr/share/gnome-session/sessions/mjdev-desktop.session 2>/dev/null || true
+                systemctl enable seatd 2>/dev/null || true
+                ldconfig 2>/dev/null || true
+            fi
+            exit 0
+            """.trimIndent() + "\n",
+        )
+        postinst.setExecutable(true, false)
+    }
+
+    /** Regenerate DEBIAN/md5sums for everything we added (paths relative to root, no leading ./). */
+    private fun regenMd5sums(work: File) {
+        val sb = StringBuilder()
+        work.walkTopDown().filter { it.isFile && !it.path.startsWith(File(work, "DEBIAN").path) }.forEach { f ->
+            val md = MessageDigest.getInstance("MD5").digest(f.readBytes())
+                .joinToString("") { "%02x".format(it) }
+            sb.append(md).append("  ").append(f.relativeTo(work).path).append('\n')
+        }
+        File(work, "DEBIAN/md5sums").writeText(sb.toString())
+    }
+
+    private fun File.copyExec(dest: File) { dest.parentFile.mkdirs(); copyTo(dest, overwrite = true); dest.setExecutable(true, false) }
+    private fun File.copyInto(dest: File, @Suppress("UNUSED_PARAMETER") mode: Int) { dest.parentFile.mkdirs(); copyTo(dest, overwrite = true) }
+
+    private fun run(vararg cmd: String) {
+        val p = ProcessBuilder(*cmd).redirectErrorStream(true).start()
+        val log = p.inputStream.bufferedReader().readText()
+        check(p.waitFor() == 0) { "[full-deb] command failed: ${cmd.joinToString(" ")}\n${log.takeLast(800)}" }
+    }
+
+    private fun which(bin: String): String? =
+        ((System.getenv("PATH") ?: "").split(File.pathSeparator) + listOf("/usr/bin", "/bin"))
+            .map { File(it, bin) }.firstOrNull { it.canExecute() }?.absolutePath
+}

+ 24 - 2
compositor/compositor.gradle.kts

@@ -161,9 +161,27 @@ tasks.register<Copy>("stageSession") {
 // built at configuration time (staged paths are static) so the Exec stays config-cache safe.
 // built at configuration time (staged paths are static) so referencing the result from doLast
 // stays configuration-cache safe (the function itself is never captured in the task action).
+// the wayland runtime stack mjdevc needs — from the version catalog (single source of truth,
+// shared with the deb Depends and make-iso.sh), never hardcoded here.
+val compositorRuntimeDeps: String = libs.versions.app.compositor.runtime.deps.get()
+
 fun sessionInstallLines(staged: File): List<String> = listOf(
     "#!/bin/sh",
     "set -e",
+    // runtime stack the compositor needs but a clean / GNOME box lacks (GNOME uses mutter,
+    // not wlroots). dpkg can't resolve these, so apt-install them explicitly. libwlroots-0.18
+    // pulls libdrm/libgbm/libinput/libseat/libxkbcommon/libwayland/libdisplay-info/libliftoff;
+    // xwayland = X display for the AWT-based Compose shell; libgl1-mesa-dri = the GL/EGL driver
+    // (incl. llvmpipe software fallback). Without these mjdevc fails to even load -> black screen.
+    "apt-get update || true",
+    "apt-get install --no-install-recommends -y $compositorRuntimeDeps seatd || " +
+            "echo 'WARN: apt could not install the wayland runtime stack (offline or non-debian?) — the desktop may not start'",
+    // the compositor opens /dev/dri/card0 + the seatd socket (group video); seatd must run and
+    // the logged-in user must be in video/input/render (+ seat if present) or the session is black.
+    "systemctl enable --now seatd 2>/dev/null || true",
+    "_u=\"\$(getent passwd \"\${PKEXEC_UID:-\${SUDO_UID:-1000}}\" | cut -d: -f1)\"",
+    "[ -n \"\$_u\" ] && usermod -aG video,input,render \"\$_u\" 2>/dev/null || true",
+    "getent group seat >/dev/null 2>&1 && [ -n \"\$_u\" ] && usermod -aG seat \"\$_u\" 2>/dev/null || true",
     "install -Dm755 '${staged.resolve("mjdevc")}' /usr/local/bin/mjdevc",
     "install -Dm755 '${staged.resolve("mjdev-session")}' /usr/local/bin/mjdev-session",
     "install -Dm644 '${staged.resolve("mjdev.desktop")}' /usr/share/wayland-sessions/mjdev.desktop",
@@ -196,7 +214,7 @@ tasks.register("installSession") {
 tasks.register<Exec>("installDesktop") {
     group = "mjdev"
     description = "Builds compositor + session + app .deb and installs it via a pkexec authentication dialog."
-    dependsOn("stageSession", ":desktopApp:packageReleaseDeb")
+    dependsOn("stageSession", ":desktopApp:packageReleaseDeb", ":packageFullDeb")
     val staged = layout.buildDirectory.dir("session-install").get().asFile
     val lines = sessionInstallLines(staged)
     // desktopApp overrides compose outputBaseDir to <root>/packages, so the .deb is written
@@ -207,7 +225,11 @@ tasks.register<Exec>("installDesktop") {
         val deb = debDir.listFiles { f -> f.extension == "deb" }?.firstOrNull()
             ?: error("desktop .deb not found in $debDir — run :desktopApp:packageReleaseDeb")
         val script = staged.resolve("install-desktop.sh")
-        script.writeText((lines + "dpkg -i '${deb.absolutePath}'").joinToString("\n") + "\n")
+        // install the deb via apt so its Depends (the wayland runtime stack baked in by
+        // packageFullDeb) are resolved; fall back to dpkg + apt -f if the apt form is unavailable.
+        val installDeb = "apt-get install --no-install-recommends -y '${deb.absolutePath}' || " +
+                "{ dpkg -i '${deb.absolutePath}' || true; apt-get install -f -y; }"
+        script.writeText((lines + installDeb).joinToString("\n") + "\n")
         // pkexec pops a graphical polkit auth dialog and runs the script as root; needs a polkit
         // agent in the session but no terminal. Fall back to a printed sudo command if absent.
         val pkexec = listOf("/usr/bin/pkexec", "/usr/local/bin/pkexec")

+ 5 - 0
deb-packages/README.md

@@ -0,0 +1,5 @@
+# extra debs for the iso
+
+Drop any additional `*.deb` here. `make-iso.sh` installs every `*.deb` found in
+this directory into the image (after the main desktop deb, no recommends).
+Leave empty for a pure wayland + mjdev-desktop image.

BIN
deb-packages/cursor-theme-mjdev.deb


BIN
deb-packages/plymouth-theme-mjdev-text.deb


BIN
deb-packages/plymouth-theme-mjdev.deb


BIN
deb-packages/sound-theme-mjdev.deb


+ 5 - 0
gradle/libs.versions.toml

@@ -10,6 +10,11 @@ app-name = "mjdev-desktop"
 app-pkg-name = "org.mjdev.desktop"
 app-pkg-version = "1.0.3"
 app-vendor = "mjdev"
+# wayland runtime stack the compositor (mjdevc) needs at runtime — single source of truth for the
+# deb Depends, the installDesktop apt step and make-iso.sh. Space-separated (shell-friendly); the
+# deb packaging joins it with ", ". A clean/GNOME box lacks these (GNOME uses mutter, not wlroots),
+# so without them mjdevc fails to load (libwlroots-0.18.so missing) and the session black-screens.
+app-compositor-runtime-deps = "libwlroots-0.18 xwayland libegl1 libgles2 libgbm1 libinput10 libseat1 libxkbcommon0 libgl1-mesa-dri dbus dbus-user-session"
 java-language-version = "17"
 # app specific android
 android-compile-sdk = "36"

+ 297 - 0
make-iso.sh

@@ -0,0 +1,297 @@
+#!/usr/bin/env bash
+# Builds mjdev-desktop-<version>.iso: a minimal, bootable Debian (latest stable)
+# live image carrying only wayland + the mjdev desktop — no X server, no desktop
+# environment, no recommends. It enables the non-free driver/firmware set, installs
+# every *.deb from deb-packages/ (plymouth + cursor + sound themes) and activates
+# them, and autostarts the mjdev wayland session on boot.
+#
+# Name + version come from the gradle version catalog (never hardcoded). The
+# desktop app deb and the compositor/session files must already be built — the
+# gradle `makeIso` task wires those dependencies and passes their paths in.
+# This script only assembles the image and must run as root (debootstrap +
+# chroot + mksquashfs).
+#
+#   sudo ./make-iso.sh                     # uses the built deb + compositor + deb-packages/
+#   sudo ./make-iso.sh --suite trixie      # pin a specific debian suite
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+CATALOG="$HERE/gradle/libs.versions.toml"
+
+read_catalog() { sed -n "s/^$1 *= *\"\(.*\)\"/\1/p" "$CATALOG"; }
+APP_NAME="$(read_catalog app-name)"
+VERSION="$(read_catalog app-pkg-version)"
+[ -n "$APP_NAME" ] && [ -n "$VERSION" ] || { echo "cannot read app name/version from $CATALOG"; exit 1; }
+# wayland runtime stack for mjdevc — single source of truth in the version catalog (not hardcoded)
+RUNTIME_DEPS="$(read_catalog app-compositor-runtime-deps)"
+
+# ---- config / args -------------------------------------------------------
+# trixie = Debian 13, the current stable ("latest public version"). A bare
+# "stable" can symlink to sid on non-debian hosts, so a concrete codename is used.
+SUITE="${MJDEV_ISO_SUITE:-trixie}"
+MIRROR="${MJDEV_ISO_MIRROR:-http://deb.debian.org/debian}"
+# building a debian rootfs from a non-debian host needs the debian archive key
+KEYRING="/usr/share/keyrings/debian-archive-keyring.gpg"
+# the gradle task overrides these; the defaults match the in-tree build outputs
+DEB="${MJDEV_ISO_DEB:-$(ls "$HERE"/packages/main-release/deb/*.deb 2>/dev/null | head -n1 || true)}"
+COMPOSITOR_BIN="${MJDEV_ISO_COMPOSITOR_BIN:-$HERE/compositor/build/session-install/mjdevc}"
+SESSION_DIR="${MJDEV_ISO_SESSION_DIR:-$HERE/compositor/build/session-install}"
+EXTRA_DEBS_DIR="${MJDEV_ISO_EXTRA_DEBS:-$HERE/deb-packages}"
+OUT="${MJDEV_ISO_OUT:-$HERE/releases/$APP_NAME-$VERSION.iso}"
+LIVE_USER="mjdev"
+WORK=""
+
+while [ $# -gt 0 ]; do
+    case "$1" in
+        --deb) DEB="$2"; shift 2;;
+        --compositor-bin) COMPOSITOR_BIN="$2"; shift 2;;
+        --session-dir) SESSION_DIR="$2"; shift 2;;
+        --extra-debs) EXTRA_DEBS_DIR="$2"; shift 2;;
+        --out) OUT="$2"; shift 2;;
+        --suite) SUITE="$2"; shift 2;;
+        --mirror) MIRROR="$2"; shift 2;;
+        --work) WORK="$2"; shift 2;;
+        -h|--help) sed -n '2,16p' "$0"; exit 0;;
+        *) echo "unknown arg: $1"; exit 1;;
+    esac
+done
+
+# tee everything to a build log so failures are diagnosable even when the script
+# is run through pkexec (which detaches the inherited stdio from the caller).
+LOG="${MJDEV_ISO_LOG:-/tmp/mjdev-iso-build.log}"
+exec > >(tee "$LOG") 2>&1
+echo ">> mjdev iso build log -> $LOG"
+
+[ "$(id -u)" -eq 0 ] || { echo "must run as root (debootstrap/chroot)"; exit 1; }
+[ -n "$DEB" ] && [ -f "$DEB" ] || { echo "desktop deb not found: '${DEB:-}'"; echo "build it first: ./gradlew :desktopApp:packageReleaseDeb"; exit 1; }
+[ -f "$COMPOSITOR_BIN" ] || { echo "compositor binary not found: $COMPOSITOR_BIN"; echo "build it first: ./gradlew :compositor:stageSession"; exit 1; }
+for t in debootstrap mksquashfs xorriso grub-mkrescue; do
+    command -v "$t" >/dev/null 2>&1 || { echo "missing tool: $t (apt install debootstrap squashfs-tools xorriso grub-common grub-pc-bin grub-efi-amd64-bin)"; exit 1; }
+done
+
+DEB="$(readlink -f "$DEB")"
+COMPOSITOR_BIN="$(readlink -f "$COMPOSITOR_BIN")"
+SESSION_DIR="$(readlink -f "$SESSION_DIR")"
+mkdir -p "$(dirname "$OUT")"; OUT="$(readlink -f "$OUT")"
+[ -d "$EXTRA_DEBS_DIR" ] && EXTRA_DEBS_DIR="$(readlink -f "$EXTRA_DEBS_DIR")" || EXTRA_DEBS_DIR=""
+WORK="${WORK:-$(mktemp -d /tmp/mjdev-iso.XXXXXX)}"
+ROOT="$WORK/rootfs"; ISO="$WORK/iso"
+mkdir -p "$ROOT" "$ISO/live" "$ISO/boot/grub"
+
+cleanup() { umount -lf "$ROOT/dev/pts" "$ROOT/dev" "$ROOT/proc" "$ROOT/sys" 2>/dev/null || true; }
+trap cleanup EXIT
+
+# ---- 1. minimal base -----------------------------------------------------
+echo ">> debootstrap $SUITE (minbase) -> $ROOT"
+DEBOOTSTRAP_OPTS="--variant=minbase"
+[ -f "$KEYRING" ] && DEBOOTSTRAP_OPTS="$DEBOOTSTRAP_OPTS --keyring=$KEYRING"
+# shellcheck disable=SC2086
+debootstrap $DEBOOTSTRAP_OPTS "$SUITE" "$ROOT" "$MIRROR"
+
+mount --bind /dev "$ROOT/dev"
+mount -t devpts devpts "$ROOT/dev/pts" 2>/dev/null || true
+mount -t proc proc "$ROOT/proc"
+mount -t sysfs sys "$ROOT/sys"
+
+# the fresh chroot has no DNS resolver — copy the host's so apt-get inside the
+# chroot can reach the mirror (without this apt-get update fails -> exit 100).
+cp -L /etc/resolv.conf "$ROOT/etc/resolv.conf" 2>/dev/null || true
+
+cat > "$ROOT/etc/apt/apt.conf.d/99lean" <<'EOF'
+APT::Install-Recommends "false";
+APT::Install-Suggests "false";
+Acquire::Languages "none";
+EOF
+
+# stage the desktop app deb, the compositor binary + session files, and every
+# extra deb (themes) into the chroot
+cp "$DEB" "$ROOT/tmp/mjdev-desktop.deb"
+install -Dm755 "$COMPOSITOR_BIN" "$ROOT/tmp/session/mjdevc"
+[ -f "$SESSION_DIR/mjdev-session" ] && install -Dm755 "$SESSION_DIR/mjdev-session" "$ROOT/tmp/session/mjdev-session"
+[ -f "$SESSION_DIR/mjdev.desktop" ] && install -Dm644 "$SESSION_DIR/mjdev.desktop" "$ROOT/tmp/session/mjdev.desktop"
+if [ -n "$EXTRA_DEBS_DIR" ] && ls "$EXTRA_DEBS_DIR"/*.deb >/dev/null 2>&1; then
+    mkdir -p "$ROOT/tmp/extra-debs"
+    cp "$EXTRA_DEBS_DIR"/*.deb "$ROOT/tmp/extra-debs/"
+fi
+
+# ---- 2-3. provision: kernel + wayland + our debs + theme activation + autostart
+cat > "$ROOT/tmp/provision.sh" <<PROVISION
+#!/bin/sh
+set -eu
+export DEBIAN_FRONTEND=noninteractive
+echo "$APP_NAME" > /etc/hostname
+
+# enable contrib + non-free + non-free-firmware so the full non-free driver and
+# firmware set is installable (minbase only enables main). deb822 or one-line,
+# whichever the base wrote.
+if [ -f /etc/apt/sources.list.d/debian.sources ]; then
+    sed -i 's/^Components:.*/Components: main contrib non-free non-free-firmware/' \
+        /etc/apt/sources.list.d/debian.sources
+else
+    echo "deb $MIRROR $SUITE main contrib non-free non-free-firmware" > /etc/apt/sources.list
+fi
+
+apt-get update
+# kernel + live boot + bare wayland runtime + plymouth (boot splash). NO xorg,
+# NO desktop environment.
+apt-get install --no-install-recommends -y \
+    linux-image-amd64 live-boot systemd-sysv \
+    dbus dbus-user-session seatd \
+    plymouth plymouth-label \
+    libgl1-mesa-dri libglx-mesa0 libegl-mesa0 \
+    fontconfig fonts-dejavu-core
+# the wayland compositor runtime stack mjdevc is linked against. libwlroots-0.18 pulls
+# its whole chain (libinput, libdrm, libgbm, libseat, libxkbcommon, libwayland-*,
+# libdisplay-info, libliftoff, libpixman, ...). XWayland: the AWT-based Compose shell
+# needs an X display, which mjdevc provides via its built-in XWayland (this is the
+# X-on-wayland shim, NOT a full xorg server). libegl1/libgles2 = the glvnd GL dispatch
+# the renderer uses. WITHOUT these mjdevc fails to even load (libwlroots-0.18.so missing)
+# -> black screen + tty1 autologin crash-loop.
+apt-get install --no-install-recommends -y $RUNTIME_DEPS
+# non-free GPU/wifi drivers + firmware so real hardware gets accelerated GL and
+# working radios (in a VM mesa still falls back to llvmpipe, which the session
+# allows). "|| true": some firmware metapackages are absent on some mirrors —
+# never fail the build for them.
+apt-get install --no-install-recommends -y \
+    mesa-va-drivers mesa-vulkan-drivers || true
+apt-get install --no-install-recommends -y \
+    firmware-linux firmware-linux-nonfree firmware-misc-nonfree \
+    firmware-iwlwifi firmware-realtek firmware-atheros || true
+
+# our desktop deb is built by jpackage on an ubuntu host, so its auto-generated
+# Depends carry one ubuntu-only name — libjpeg-turbo8 (ubuntu's libjpeg.so.8),
+# which does not exist in debian (debian ships libjpeg62-turbo). the app is
+# self-contained (bundled jre + skiko, which carries its own image codecs), so the
+# dep is spurious. strip it from the control file before installing — that leaves a
+# fully-satisfiable package, so apt resolves the rest normally and never ends up in
+# a permanently "broken" state that would block apt autoremove during cleanup.
+dpkg-deb -R /tmp/mjdev-desktop.deb /tmp/deb-fix
+sed -i -E 's/, *libjpeg-turbo8//; s/libjpeg-turbo8, *//; s/^(Depends:) *libjpeg-turbo8 *\$/\1/' \
+    /tmp/deb-fix/DEBIAN/control
+# the deb's postinst runs "xdg-desktop-menu install", which fails in a minbase chroot
+# (no desktop-environment menu infrastructure). the menu entry is cosmetic — the session
+# execs /opt/mjdev-desktop directly — so make that call non-fatal instead of fighting
+# xdg-desktop-menu's DE detection. we still drop the .desktop into /usr/share/applications.
+mkdir -p /usr/share/applications
+sed -i 's/^xdg-desktop-menu install .*/& || true/' /tmp/deb-fix/DEBIAN/postinst
+dpkg-deb -b /tmp/deb-fix /tmp/mjdev-desktop-fixed.deb
+apt-get install --no-install-recommends -y /tmp/mjdev-desktop-fixed.deb
+install -Dm644 /opt/mjdev-desktop/lib/mjdev-desktop-mjdev-desktop.desktop \
+    /usr/share/applications/mjdev-desktop.desktop 2>/dev/null || true
+
+# every extra deb from deb-packages/ (plymouth/cursor/sound themes). their deps
+# (plymouth, plymouth-label) are already in the base; their postinst scripts register
+# the update-alternatives activated below.
+if ls /tmp/extra-debs/*.deb >/dev/null 2>&1; then
+    apt-get install --no-install-recommends -y /tmp/extra-debs/*.deb
+fi
+
+# compositor binary + wayland session launcher + the wayland-sessions entry
+install -Dm755 /tmp/session/mjdevc /usr/bin/mjdevc
+[ -f /tmp/session/mjdev-session ] && install -Dm755 /tmp/session/mjdev-session /usr/bin/mjdev-session
+[ -f /tmp/session/mjdev.desktop ] && install -Dm644 /tmp/session/mjdev.desktop /usr/share/wayland-sessions/mjdev.desktop
+
+# ---- activate the themes -------------------------------------------------
+# plymouth: make mjdev the default boot theme and rebuild the initramfs so the
+# splash is embedded. -R rebuilds; fall back to the alternative + update-initramfs.
+if [ -f /usr/share/plymouth/themes/mjdev/mjdev.plymouth ]; then
+    plymouth-set-default-theme -R mjdev 2>/dev/null || {
+        update-alternatives --set default.plymouth \
+            /usr/share/plymouth/themes/mjdev/mjdev.plymouth || true
+        update-initramfs -u || true
+    }
+fi
+# cursor: the postinst registered bloom under x-cursor-theme; select it and
+# export it for wayland clients (which read XCURSOR_THEME, not the X alternative).
+if [ -f /usr/share/icons/bloom/cursor.theme ]; then
+    update-alternatives --set x-cursor-theme /usr/share/icons/bloom/cursor.theme || true
+    echo 'XCURSOR_THEME=bloom' >> /etc/environment
+fi
+# sound: point the freedesktop sound theme at mjdev for libcanberra / our shell.
+if [ -d /usr/share/sounds/mjdev ]; then
+    echo 'SOUND_THEME=mjdev' >> /etc/environment
+    echo 'MJDEV_SOUND_THEME=mjdev' >> /etc/environment
+fi
+
+# passwordless live user, autologin tty1, straight into the wayland session
+useradd -m -s /bin/bash $LIVE_USER
+passwd -d $LIVE_USER
+# the compositor opens /dev/dri/card0 and /run/seatd.sock (group "video"); without
+# these groups the user gets no DRM seat and the session is a black screen. usermod,
+# not adduser — minbase ships usermod (passwd) but not the adduser wrapper.
+usermod -aG video,input,render $LIVE_USER
+getent group seat >/dev/null 2>&1 && usermod -aG seat $LIVE_USER || true
+systemctl enable seatd
+
+mkdir -p /etc/systemd/system/getty@tty1.service.d
+cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf <<EOF
+[Service]
+ExecStart=
+ExecStart=-/sbin/agetty --autologin $LIVE_USER --noclear %I \\\$TERM
+EOF
+
+cat > /home/$LIVE_USER/.bash_profile <<EOF
+# start the mjdev desktop on boot (tty1 only)
+if [ -z "\\\$WAYLAND_DISPLAY" ] && [ "\\\$(tty)" = "/dev/tty1" ]; then
+    exec mjdev-session
+fi
+EOF
+chown $LIVE_USER:$LIVE_USER /home/$LIVE_USER/.bash_profile
+PROVISION
+chmod +x "$ROOT/tmp/provision.sh"
+chroot "$ROOT" /tmp/provision.sh
+
+# ---- 4. clean_system: strip everything not needed -> smallest image ------
+clean_system() {
+    echo ">> clean_system: stripping docs, locales, caches, autoremove"
+    cat > "$ROOT/tmp/clean.sh" <<'CLEAN'
+#!/bin/sh
+set -eu
+export DEBIAN_FRONTEND=noninteractive
+apt-get -y autoremove --purge
+apt-get -y clean
+rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
+rm -rf /usr/share/doc/* /usr/share/man/* /usr/share/info/*
+find /usr/share/locale -mindepth 1 -maxdepth 1 ! -name 'en*' ! -name 'cs*' \
+    -exec rm -rf {} + 2>/dev/null || true
+rm -f /etc/apt/apt.conf.d/99lean
+CLEAN
+    chmod +x "$ROOT/tmp/clean.sh"
+    chroot "$ROOT" /tmp/clean.sh
+    rm -rf "$ROOT/tmp/provision.sh" "$ROOT/tmp/clean.sh" \
+        "$ROOT/tmp/mjdev-desktop.deb" "$ROOT/tmp/session" "$ROOT/tmp/extra-debs"
+}
+clean_system
+
+# ---- 5. squash + assemble bootable iso -----------------------------------
+echo ">> exporting kernel + initrd"
+cp "$ROOT"/boot/vmlinuz-* "$ISO/live/vmlinuz"
+cp "$ROOT"/boot/initrd.img-* "$ISO/live/initrd.img"
+
+cleanup
+echo ">> mksquashfs (bulk of the time)"
+# xz + x86 BCJ filter = smallest squashfs for an amd64 rootfs
+mksquashfs "$ROOT" "$ISO/live/filesystem.squashfs" \
+    -comp xz -Xbcj x86 -b 1M -noappend -e boot
+
+cat > "$ISO/boot/grub/grub.cfg" <<'EOF'
+set default=0
+set timeout=3
+menuentry "mjdev desktop (live)" {
+    linux /live/vmlinuz boot=live quiet splash
+    initrd /live/initrd.img
+}
+EOF
+
+echo ">> grub-mkrescue -> $OUT"
+grub-mkrescue -o "$OUT" "$ISO"
+
+# we run as root (pkexec/sudo); hand the iso back to the invoking user so it isn't
+# a root-owned file sitting in releases/. PKEXEC_UID (pkexec) / SUDO_UID (sudo).
+OWNER_UID="${PKEXEC_UID:-${SUDO_UID:-}}"
+[ -n "$OWNER_UID" ] && chown "$OWNER_UID":"$OWNER_UID" "$OUT" 2>/dev/null || true
+echo ">> done: $OUT ($(du -h "$OUT" | cut -f1))"
+
+# the iso is built — drop the (root-owned) scratch rootfs so it doesn't pile up
+# in /tmp. on failure WORK is kept (the trap only unmounts) for debugging.
+rm -rf "$WORK"

+ 79 - 0
run-iso-qemu.sh

@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# Boots mjdev-desktop-<version>.iso in QEMU so the wayland desktop can be tried
+# without touching the host session. Uses KVM and virtio-gpu GL acceleration
+# when available (the desktop needs GL), falls back to plain emulation.
+#
+#   ./run-iso-qemu.sh                 # boots releases/<app>-<version>.iso (local gtk window)
+#   ./run-iso-qemu.sh path/to.iso     # boots a specific image
+#   ./run-iso-qemu.sh --vnc           # headless: expose the screen over VNC for remote
+#                                     # grab + control (127.0.0.1:5900). egl-headless keeps
+#                                     # GL working without a host display. Connect with any
+#                                     # VNC client / vncsnapshot / gvncviewer.
+#   MJDEV_QEMU_VNC=1 ./run-iso-qemu.sh           # same via env
+#   MJDEV_QEMU_VNC_DISPLAY=2 ./run-iso-qemu.sh --vnc   # 127.0.0.1:5902
+#   MJDEV_QEMU_VNC_HOST=0.0.0.0 ./run-iso-qemu.sh --vnc  # listen on all interfaces
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+CATALOG="$HERE/gradle/libs.versions.toml"
+read_catalog() { sed -n "s/^$1 *= *\"\(.*\)\"/\1/p" "$CATALOG"; }
+APP_NAME="$(read_catalog app-name)"
+VERSION="$(read_catalog app-pkg-version)"
+
+VNC="${MJDEV_QEMU_VNC:-0}"
+ISO=""
+for a in "$@"; do
+    case "$a" in
+        --vnc) VNC=1;;
+        *) ISO="$a";;
+    esac
+done
+ISO="${ISO:-$HERE/releases/$APP_NAME-$VERSION.iso}"
+RAM="${MJDEV_QEMU_RAM:-4096}"
+CPUS="${MJDEV_QEMU_CPUS:-4}"
+VNC_HOST="${MJDEV_QEMU_VNC_HOST:-127.0.0.1}"
+VNC_DISPLAY="${MJDEV_QEMU_VNC_DISPLAY:-0}"
+
+command -v qemu-system-x86_64 >/dev/null 2>&1 || { echo "qemu-system-x86_64 not found (apt install qemu-system-x86)"; exit 1; }
+[ -f "$ISO" ] || { echo "iso not found: $ISO"; echo "build it first: ./gradlew makeIso"; exit 1; }
+
+ARGS=(
+    -m "$RAM"
+    -smp "$CPUS"
+    -cdrom "$ISO"
+    -boot d
+)
+
+# hardware acceleration when the host exposes it
+if [ -w /dev/kvm ]; then
+    ARGS+=(-enable-kvm -cpu host)
+    echo ">> KVM enabled"
+else
+    echo ">> /dev/kvm not available - software emulation (slow)"
+fi
+
+if [ "$VNC" = "1" ]; then
+    # headless screen grab + control over VNC. egl-headless renders the guest GPU
+    # output (so the wayland compositor still gets real GL) and hands the framebuffer
+    # to qemu's built-in VNC server; VNC injects keyboard/pointer back into the guest.
+    # Independent of the guest compositor — no in-image vnc server / wlroots screencopy
+    # needed. Listens on $VNC_HOST:(5900+$VNC_DISPLAY).
+    if qemu-system-x86_64 -display help 2>/dev/null | grep -q egl-headless; then
+        ARGS+=(-device virtio-vga-gl -display "egl-headless,gl=on")
+    else
+        echo ">> egl-headless unavailable - VNC without GL (compositor falls back to llvmpipe)"
+        ARGS+=(-device virtio-vga)
+    fi
+    ARGS+=(-vnc "$VNC_HOST:$VNC_DISPLAY")
+    echo ">> VNC: connect to $VNC_HOST:$((5900 + VNC_DISPLAY))  (e.g. vncviewer $VNC_HOST:$VNC_DISPLAY)"
+# virtio-gpu with GL gives the wayland compositor a real GPU path; gtk display
+# with gl=on renders it on the host. Fall back to a plain virtio-gpu if the
+# host qemu has no GL display backend.
+elif qemu-system-x86_64 -display help 2>/dev/null | grep -q gtk; then
+    ARGS+=(-device virtio-vga-gl -display gtk,gl=on)
+else
+    ARGS+=(-device virtio-vga -display sdl)
+fi
+
+echo ">> booting $ISO"
+exec qemu-system-x86_64 "${ARGS[@]}"

+ 18 - 1
session/mjdev-session

@@ -7,6 +7,18 @@ export XDG_SESSION_TYPE=wayland
 export MOZ_ENABLE_WAYLAND=1
 export QT_QPA_PLATFORM="wayland;xcb"
 export _JAVA_AWT_WM_NONREPARENTING=1
+# allow wlroots to start on llvmpipe (software GL). On real hardware mesa uses the
+# GPU and this is ignored; in a VM / headless box without GPU passthrough mesa falls
+# back to llvmpipe and wlroots otherwise refuses to create a renderer ("Software
+# rendering detected, please use WLR_RENDERER_ALLOW_SOFTWARE") and the compositor
+# exits immediately -> black screen + tty1 autologin crash-loop.
+export WLR_RENDERER_ALLOW_SOFTWARE=1
+# the Compose shell renders via Skiko. On a software/llvmpipe stack (VM, or a box with
+# no GPU) the compositor's XWayland has no glamor, so Skiko cannot create an OpenGL/GLX
+# context -> "org.jetbrains.skiko.RenderException: Cannot create Linux GL context" and the
+# shell crash-loops. Skiko's CPU raster backend needs no GL context and works everywhere.
+# (On real GPUs this is the safe-but-slower path; revisit once GPU detection is added.)
+export SKIKO_RENDER_API=SOFTWARE
 
 # desktop shell binary - deb/appimage install location, gradle dev fallback
 SHELL_CMD="/opt/mjdev-desktop/bin/mjdev-desktop"
@@ -32,4 +44,9 @@ cleanup() {
 }
 trap cleanup EXIT HUP INT TERM
 
-"$MJDEVC" --session --shell-cmd "$SHELL_CMD"
+# log the compositor + shell output so a failed start is diagnosable (otherwise the
+# tty1 session just dies to a black screen with no trace). keep the previous run.
+LOG_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/mjdev-desktop"
+mkdir -p "$LOG_DIR"
+[ -f "$LOG_DIR/session.log" ] && mv -f "$LOG_DIR/session.log" "$LOG_DIR/session.log.1"
+"$MJDEVC" --session --shell-cmd "$SHELL_CMD" >"$LOG_DIR/session.log" 2>&1

+ 1 - 2
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/processes/ProcessManager.kt

@@ -111,8 +111,7 @@ class ProcessManager(
                 toList().any { p -> p.pid == pid }
             }
 
-        fun SnapshotStateList<ProcessWrapper>.containsProcess(ph: ProcessWrapper) =
-            toList().any { p -> p.pid == ph.pid }
+        fun SnapshotStateList<ProcessWrapper>.containsProcess(ph: ProcessWrapper) = toList().any { p -> p.pid == ph.pid }
 
         @Composable
         fun processManagerListener(onChanged: IProcessManager?.(processHandle: ProcessHandle?) -> Unit = {}) =

+ 2 - 2
shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/ChromeWindowState.kt

@@ -84,7 +84,7 @@ open class ChromeWindowState(
 
     override var position: DpOffset = position
         set(value) {
-            Log.d("ChromeWindow position: ${field} -> $value")
+            Log.d("ChromeWindow position: $field -> $value")
             field = value
             if (isCreated) {
                 scope.launch {
@@ -95,7 +95,7 @@ open class ChromeWindowState(
 
     override var size: DpSize = size
         set(value) {
-            Log.d("ChromeWindow size: ${field} -> $value")
+            Log.d("ChromeWindow size: $field -> $value")
             val oldSize = field
             field = value
             if (isCreated) {