Milan Jurkulak пре 3 месеци
родитељ
комит
e4c8c0bc14
92 измењених фајлова са 27604 додато и 0 уклоњено
  1. 13 0
      .claude/settings.local.json
  2. 21 0
      .claude/skills/compose-skill/LICENSE
  3. 204 0
      .claude/skills/compose-skill/SKILL.md
  4. 195 0
      .claude/skills/compose-skill/references/accessibility.md
  5. 238 0
      .claude/skills/compose-skill/references/animations-advanced.md
  6. 191 0
      .claude/skills/compose-skill/references/animations.md
  7. 109 0
      .claude/skills/compose-skill/references/anti-patterns.md
  8. 204 0
      .claude/skills/compose-skill/references/architecture.md
  9. 290 0
      .claude/skills/compose-skill/references/ci-cd-distribution.md
  10. 210 0
      .claude/skills/compose-skill/references/clean-code.md
  11. 232 0
      .claude/skills/compose-skill/references/compose-essentials.md
  12. 119 0
      .claude/skills/compose-skill/references/coroutines-flow-advanced.md
  13. 188 0
      .claude/skills/compose-skill/references/coroutines-flow.md
  14. 232 0
      .claude/skills/compose-skill/references/cross-platform.md
  15. 197 0
      .claude/skills/compose-skill/references/datastore.md
  16. 82 0
      .claude/skills/compose-skill/references/dependency-injection.md
  17. 298 0
      .claude/skills/compose-skill/references/gradle-build.md
  18. 278 0
      .claude/skills/compose-skill/references/hilt.md
  19. 198 0
      .claude/skills/compose-skill/references/image-loading.md
  20. 208 0
      .claude/skills/compose-skill/references/ios-swift-interop.md
  21. 260 0
      .claude/skills/compose-skill/references/koin.md
  22. 161 0
      .claude/skills/compose-skill/references/lists-grids.md
  23. 246 0
      .claude/skills/compose-skill/references/material-design.md
  24. 220 0
      .claude/skills/compose-skill/references/mvi.md
  25. 236 0
      .claude/skills/compose-skill/references/mvvm.md
  26. 139 0
      .claude/skills/compose-skill/references/navigation-2-di.md
  27. 251 0
      .claude/skills/compose-skill/references/navigation-2.md
  28. 174 0
      .claude/skills/compose-skill/references/navigation-3-di.md
  29. 229 0
      .claude/skills/compose-skill/references/navigation-3.md
  30. 120 0
      .claude/skills/compose-skill/references/navigation-migration.md
  31. 91 0
      .claude/skills/compose-skill/references/navigation.md
  32. 237 0
      .claude/skills/compose-skill/references/networking-ktor-architecture.md
  33. 204 0
      .claude/skills/compose-skill/references/networking-ktor-auth.md
  34. 153 0
      .claude/skills/compose-skill/references/networking-ktor-testing.md
  35. 270 0
      .claude/skills/compose-skill/references/networking-ktor.md
  36. 150 0
      .claude/skills/compose-skill/references/paging-mvi-testing.md
  37. 130 0
      .claude/skills/compose-skill/references/paging-offline.md
  38. 219 0
      .claude/skills/compose-skill/references/paging.md
  39. 166 0
      .claude/skills/compose-skill/references/performance.md
  40. 206 0
      .claude/skills/compose-skill/references/resources.md
  41. 256 0
      .claude/skills/compose-skill/references/room-database.md
  42. 222 0
      .claude/skills/compose-skill/references/testing.md
  43. 170 0
      .claude/skills/compose-skill/references/ui-ux.md
  44. 2204 0
      .claude/skills/compose-skill/scripts/validate.sh
  45. 107 0
      .claude/skills/debugging-wizard/SKILL.md
  46. 132 0
      .claude/skills/debugging-wizard/references/common-patterns.md
  47. 140 0
      .claude/skills/debugging-wizard/references/debugging-tools.md
  48. 177 0
      .claude/skills/debugging-wizard/references/quick-fixes.md
  49. 142 0
      .claude/skills/debugging-wizard/references/strategies.md
  50. 367 0
      .claude/skills/debugging-wizard/references/systematic-debugging.md
  51. 1082 0
      .claude/skills/kmp-compose-multiplatform/SKILL.md
  52. 522 0
      .claude/skills/kmp-compose-multiplatform/references/architecture.md
  53. 564 0
      .claude/skills/kmp-compose-multiplatform/references/build-system.md
  54. 653 0
      .claude/skills/kmp-compose-multiplatform/references/compose-best-practices.md
  55. 386 0
      .claude/skills/kmp-compose-multiplatform/references/error-handling.md
  56. 285 0
      .claude/skills/kmp-compose-multiplatform/references/i18n.md
  57. 506 0
      .claude/skills/kmp-compose-multiplatform/references/ios-interop.md
  58. 504 0
      .claude/skills/kmp-compose-multiplatform/references/navigation.md
  59. 695 0
      .claude/skills/kmp-compose-multiplatform/references/testing.md
  60. 260 0
      .claude/skills/kotlin-build-kmp-gradle-governance/SKILL.md
  61. 310 0
      .claude/skills/kotlin-data-kmp-data-layer/SKILL.md
  62. 977 0
      .claude/skills/kotlin-kmp-code-review/SKILL.md
  63. 108 0
      .claude/skills/kotlin-kmp-refactor-safety/SKILL.md
  64. 410 0
      .claude/skills/kotlin-navigation-compose-multiplatform/SKILL.md
  65. 341 0
      .claude/skills/kotlin-platform-app-links-and-deep-links/SKILL.md
  66. 346 0
      .claude/skills/kotlin-platform-kmp-bridges/SKILL.md
  67. 751 0
      .claude/skills/kotlin-project-architecture-review/SKILL.md
  68. 399 0
      .claude/skills/kotlin-project-bugfix/SKILL.md
  69. 993 0
      .claude/skills/kotlin-project-feature-implementation/SKILL.md
  70. 417 0
      .claude/skills/kotlin-project-modularization/SKILL.md
  71. 447 0
      .claude/skills/kotlin-project-state-management/SKILL.md
  72. 149 0
      .claude/skills/kotlin-specialist/SKILL.md
  73. 419 0
      .claude/skills/kotlin-specialist/references/android-compose.md
  74. 276 0
      .claude/skills/kotlin-specialist/references/coroutines-flow.md
  75. 421 0
      .claude/skills/kotlin-specialist/references/dsl-idioms.md
  76. 426 0
      .claude/skills/kotlin-specialist/references/ktor-server.md
  77. 380 0
      .claude/skills/kotlin-specialist/references/multiplatform-kmp.md
  78. 499 0
      .claude/skills/kotlin-testing-kmp/SKILL.md
  79. 438 0
      .claude/skills/kotlin-ui-adaptive-resources/SKILL.md
  80. 395 0
      .claude/skills/kotlin-ui-compose-multiplatform/SKILL.md
  81. 96 0
      .claude/skills/test-master/SKILL.md
  82. 294 0
      .claude/skills/test-master/references/automation-frameworks.md
  83. 128 0
      .claude/skills/test-master/references/e2e-testing.md
  84. 120 0
      .claude/skills/test-master/references/integration-testing.md
  85. 118 0
      .claude/skills/test-master/references/performance-testing.md
  86. 247 0
      .claude/skills/test-master/references/qa-methodology.md
  87. 127 0
      .claude/skills/test-master/references/security-testing.md
  88. 174 0
      .claude/skills/test-master/references/tdd-iron-laws.md
  89. 104 0
      .claude/skills/test-master/references/test-reports.md
  90. 231 0
      .claude/skills/test-master/references/testing-anti-patterns.md
  91. 113 0
      .claude/skills/test-master/references/unit-testing.md
  92. 7 0
      .gitignore

+ 13 - 0
.claude/settings.local.json

@@ -0,0 +1,13 @@
+{
+  "permissions": {
+    "allow": [
+      "WebSearch",
+      "Read(//tmp/kmp-skill-src/.claude/skills/kmp-compose-multiplatform/**)",
+      "Bash(mkdir -p .claude/skills)",
+      "Bash(cp -r /tmp/kmp-skill-src/.claude/skills/kmp-compose-multiplatform .claude/skills/)",
+      "WebFetch(domain:github.com)",
+      "Read(//tmp/**)",
+      "Bash(rm -rf dpconde-src chrisbanes-src)"
+    ]
+  }
+}

+ 21 - 0
.claude/skills/compose-skill/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Meet Miyani
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 204 - 0
.claude/skills/compose-skill/SKILL.md

@@ -0,0 +1,204 @@
+---
+name: compose-skill
+license: MIT
+description: >
+  Jetpack Compose and Compose Multiplatform (KMP/CMP) architecture skill.
+  Only use when the user explicitly mentions "compose-skill", "@compose-skill",
+  or "use compose skill" in their message. Do NOT auto-activate based on
+  keyword matching — this skill should only be triggered by direct user request.
+---
+
+# Jetpack Compose & Compose Multiplatform
+
+This skill covers the full Compose app development lifecycle — from architecture and state management through UI, networking, persistence, performance, accessibility, cross-platform sharing, build configuration, and distribution. Jetpack Compose and Compose Multiplatform share the same core APIs and mental model. **Not all Jetpack libraries work in `commonMain`** — many remain Android-only. A subset of AndroidX libraries now publish multiplatform artifacts (e.g., `lifecycle-viewmodel`, `lifecycle-runtime-compose`, `datastore-preferences`), but availability and API surface vary by version. **Before adding any Jetpack/AndroidX dependency to `commonMain`, verify the artifact is published for all required targets by checking Maven Central or the library's official documentation.** CMP uses `expect/actual` or interfaces for platform-specific code. MVI (Model-View-Intent) is the recommended architecture, but the skill adapts to existing project conventions.
+
+## Existing Project Policy
+
+**Do not force migration.** If a project already follows MVI with its own conventions (different base class, different naming, different file layout), respect that. Adapt to the project's existing patterns. The architecture pattern — unidirectional data flow with Event, State, and Effect — is what matters, not a specific base class or framework. Only suggest structural changes when the user asks for them or when the existing code has clear architectural violations (business logic in composables, scattered state mutations, etc.).
+
+## Workflow
+
+When helping with Jetpack Compose or Compose Multiplatform code, follow this process:
+
+1. **Read the existing code first for context** — check conventions, base classes, and layout. For small UI or logic asks, restrict your reading to the immediately relevant files to save time. Do not map out the entire project architecture unless a structural refactor is requested.
+2. **Identify the concern** — is this architecture, state modeling, performance, navigation, DI, animation, cross-platform, or testing?
+3. **Apply the core rules below** — the decision heuristics and defaults in this file cover most cases.
+4. **Consult the right reference** — load the relevant file from `references/` only when deeper guidance is needed. Use the [Quick Routing](#quick-routing) in the Detailed References section to pick the right file.
+5. **Verify dependencies before recommending** — before adding or upgrading any dependency, verify coordinates, target support, and API shape via a documentation MCP tool or official docs (see [Dependency Verification Rule](#dependency-verification-rule)).
+6. **Flag anti-patterns contextually** — if the user's code violates best practices, call it out for production code. For quick prototypes or minor UI tweaks, prioritize answering their specific question over lecturing them on strict rules.
+7. **Write the minimal correct solution** — do not over-engineer. Prefer feature-specific code over generic frameworks.
+
+## Dependency Verification Rule
+
+**Before recommending any new dependency or version upgrade, verify:**
+
+1. **Coordinates** — Confirm the exact Maven coordinates (`group:artifact:version`) exist and are current.
+2. **Target support** — Confirm the artifact supports the project's targets (Android, iOS, Desktop, `commonMain`). Do not assume a Jetpack library works in `commonMain` unless verified.
+3. **API shape** — Confirm the API you plan to use actually exists in that version. Function signatures, parameter names, and return types change between major versions.
+
+**How to verify:**
+- **Documentation MCP tool** (preferred) — If a documentation MCP server is available (e.g., Context7), verify exact tool names and schemas first, then use it to fetch current official documentation for the library.
+- **Official docs** — Search the library's official documentation or release notes.
+- **Maven Central / Google Maven** — Check artifact availability and supported platforms.
+
+**If verification is not possible** (no documentation tool, no network access, docs unavailable), **provide the standard or latest known dependency snippet anyway.** Add a brief comment (e.g., `// Verify latest version`) so the user isn't blocked.
+
+## Fetching Up-to-Date Documentation
+
+When adding a new dependency, upgrading major versions, or verifying latest API patterns, use a **documentation MCP tool** (e.g., Context7) if available. Before invoking, verify the tool's exact name and parameter schema — tool names vary across environments.
+
+1. **Resolve library ID** — if the tool requires a resolution step, call it first.
+2. **Query docs** — call with the resolved ID and a specific question.
+
+**Alternative**: Users can add `use context7` (or equivalent) to their prompt. Bundled references remain the primary source for architectural patterns and MVI guidance; use documentation tools for API-specific and version-specific queries.
+
+## Core Architecture: MVI or MVVM
+
+Both MVI and MVVM use **unidirectional data flow**: UI renders state → user acts → ViewModel updates state → UI re-renders. The difference is how UI actions reach the ViewModel.
+
+- **MVI**: `sealed interface Event` + single `onEvent()` entry point
+- **MVVM**: Named public functions (`onTitleChanged()`, `save()`)
+
+Both patterns use:
+- **State** — immutable data class that fully describes the screen, owned via `StateFlow`
+- **Effect** — one-shot commands (navigate, snackbar, share) delivered via `Channel`
+
+**Default recommendation:** Preserve the project's existing pattern when it is coherent. For new projects, choose based on team preference and screen complexity. See [Architecture & State Management](references/architecture.md) for the decision guide, then [mvi.md](references/mvi.md) or [mvvm.md](references/mvvm.md) for implementation details.
+
+### UI Rendering Boundary
+
+These boundaries apply to both MVI and MVVM:
+
+- **Route** composable: obtains ViewModel, collects state via `collectAsStateWithLifecycle()`, collects effects via `CollectEffect` (see [compose-essentials.md](references/compose-essentials.md)), binds navigation/snackbar/platform APIs
+- **Screen** composable: stateless renderer — receives state and callbacks (MVI: `onEvent`, MVVM: individual callbacks), renders the screen, adapts callbacks for leaf composables
+- **Leaf** composables: render sub-state, emit specific callbacks, keep only tiny visual-local state (focus, scroll, animation)
+
+## Decision Heuristics
+
+- Composable functions render state and emit events, never decide business rules
+- If a value can be derived from state, do not store it redundantly unless async/persistence/performance justifies it
+- Event handling in the ViewModel owns state transitions; composables do not mutate state
+- UI-local state is acceptable only for ephemeral visual concerns: focus, scroll, animation progress, expansion toggles
+- Do not push animation-only flags into global screen state unless business logic depends on them
+- Pass the narrowest possible state to leaf composables
+- MVI: implement `onEvent()` as the single entry point; MVVM: implement named functions for user actions
+- Do not introduce a use case for every repository call
+- Cross-platform sharing prioritizes business logic and presentation state before platform behavior
+- Least recomposition is achieved by state shape and read boundaries first, Compose APIs second
+- When a project has an existing MVI base class or pattern, use it — don't introduce a competing abstraction
+
+## State Modeling
+
+For calculator/form screens, split state into four buckets:
+
+1. **Editable input** — raw text and choice values as the user edits them
+2. **Derived display/business** — parsed, validated, calculated values
+3. **Persisted domain snapshot** — saved entity for dirty tracking or reset
+4. **Transient UI-only** — purely visual, not business-significant
+
+| Concern | Where | Example |
+|---|---|---|
+| Raw field text | `state` fields | `"12"`, `"12."`, `""` |
+| Parsed/derived | `state` computed props or fields | `val hasRequiredFields: Boolean` |
+| Validation | `state.validationErrors` or similar | `mapOf("name" to "Required")` |
+| Loading/refresh | `state` flags | `isSaving = true` |
+| One-off UI commands | `Effect` via Channel | snackbar, navigate, share |
+| Scroll/focus/animation | local Compose state | `LazyListState`, focus requester |
+
+## Recommended Defaults
+
+Apply these unless the project already follows a different coherent pattern.
+
+| Concern | Default |
+|---|---|
+| ViewModel | One ViewModel per screen (`commonMain` for CMP, feature package for Android-only). MVI: `onEvent(Event)` entry point; MVVM: named functions |
+| State source of truth | `StateFlow<FeatureState>` owned by the ViewModel |
+| Event handling | MVI: `onEvent(event)` with `when` expression; MVVM: named functions. Both map user actions to state updates, effect emissions, and async launches |
+| Side effects | `Effect` sent via `Channel<Effect>(Channel.BUFFERED)` for UI-consumed one-shots (navigate, snackbar). Async work (network, persistence) launched in `viewModelScope` |
+| Async loading | Keep previous content, flip loading flag, cancel outdated jobs, update state on completion |
+| Dumb UI contract | Render props, emit explicit callbacks, keep only ephemeral visual state local |
+| Resource access | Semantic keys/enums in state; resolve strings/icons close to UI. CMP uses `Res.string` / `Res.drawable` (not Android `R`). See [Resources](references/resources.md) |
+| Platform separation | CMP: share in `commonMain`, `expect/actual` (verify Kotlin 1.9 vs 2.0+ via `build.gradle.kts` or ask user) or interfaces, Koin DI by default. Android-only: standard package, Hilt or Koin DI |
+| Navigation | ViewModel emits semantic navigation effect; route/navigation layer executes it |
+| Persistence (settings) | DataStore Preferences in `commonMain` for key-value settings; Typed DataStore (JSON) for structured settings objects; Room for relational/queried data. See [DataStore](references/datastore.md) |
+| Testing | ViewModel event→state→effect tests via Turbine in `commonTest`; validators/calculators tested as pure functions; platform bindings tested per target |
+
+## Do / Don't Quick Reference
+
+### Do
+
+- Model raw editable text separately from parsed values
+- Keep state immutable and equality-friendly
+- Reuse unchanged nested objects when possible
+- Emit semantic effects instead of making platform calls from event handling
+- Preserve old content during refresh
+- Map domain data to UI state close to the presentation boundary
+- Use feature-specific ViewModel names
+- Key list items by stable domain ID
+- Import all types and functions at the top of the file; use `import ... as ...` aliases to resolve name clashes
+- Guard no-op state emissions (don't update state if nothing changed)
+- Respect the project's existing MVI conventions
+
+### Don't
+
+- Parse numbers in composable bodies
+- Run network requests from composables
+- Store `MutableState`, controllers, lambdas, or platform objects in screen state
+- Encode snackbar/navigation as "consume once" booleans in state — use effects
+- Keep every minor visual toggle in the ViewModel state
+- Pass entire state to every child composable
+- Wrap every repository call in a use case class
+- Wipe the screen with a full-screen spinner during refresh
+- Force-migrate a working codebase to a different architecture or base class
+- Use fully qualified package paths inline (e.g., `com.example.pkg.SomeClass.method()`) — always import at file top
+
+## Detailed References
+
+**Do not load reference files for basic Compose usage.** If you already know how to build the required UI or logic, write the code immediately. **Load exactly one reference file only when the task involves advanced concepts** (e.g., Paging 3, Nav 3 setup). Pick the right file below — do not load files speculatively.
+
+### Quick Routing
+
+- **Recomposition too frequent, stability, or Compose Compiler Metrics** → [performance.md](references/performance.md)
+- **Channel vs SharedFlow, Flow operators, structured concurrency, or exception handling** → [coroutines-flow.md](references/coroutines-flow.md)
+- **Backpressure, callbackFlow, Mutex/Semaphore, or Turbine testing** → [coroutines-flow-advanced.md](references/coroutines-flow-advanced.md)
+- **Nav 3 routes, tabs, scenes, deep links, or back stack patterns** → [navigation-3.md](references/navigation-3.md)
+- **Nav 2 NavHost, tabs, deep links, nested graphs, or animations** → [navigation-2.md](references/navigation-2.md)
+- **Wiring Hilt or Koin with navigation** → [navigation-3-di.md](references/navigation-3-di.md) or [navigation-2-di.md](references/navigation-2-di.md) based on version
+- **Migrating from Nav 2 to Nav 3** → [navigation-migration.md](references/navigation-migration.md)
+- **Paging 3 setup, PagingSource, filters, LoadState, or transformations** → [paging.md](references/paging.md)
+- **Offline-first paging with Room and RemoteMediator** → [paging-offline.md](references/paging-offline.md)
+- **Paging MVI integration, paging tests, or paging anti-patterns** → [paging-mvi-testing.md](references/paging-mvi-testing.md)
+- **Ktor client setup, plugins, DTOs, API service, or repository pattern** → [networking-ktor.md](references/networking-ktor.md)
+- **Auth (bearer), WebSockets, or SSE** → [networking-ktor-auth.md](references/networking-ktor-auth.md)
+- **Network layer architecture, plugin composition, or error handling strategy** → [networking-ktor-architecture.md](references/networking-ktor-architecture.md)
+- **Choosing Hilt vs Koin** → [dependency-injection.md](references/dependency-injection.md) first, then the chosen framework's file
+- **Accessibility audit, semantics, touch targets, or WCAG contrast** → [accessibility.md](references/accessibility.md)
+- **Animation API selection (animate*AsState, Animatable, transitions, AnimatedVisibility)** → [animations.md](references/animations.md)
+- **Shared element transitions, gesture-driven animations, Canvas, or graphicsLayer** → [animations-advanced.md](references/animations-advanced.md)
+- **Code review or anti-pattern detection** → [anti-patterns.md](references/anti-patterns.md) first, then domain-specific files as needed
+- **Exposing Kotlin to Swift, SKIE, or Flow→AsyncSequence** → [ios-swift-interop.md](references/ios-swift-interop.md)
+- **ViewModel pipeline, state modeling, domain layer, or inter-feature communication** → [architecture.md](references/architecture.md)
+- **MVI pipeline, Event/State/Effect, onEvent pattern, or effect delivery** → [mvi.md](references/mvi.md)
+- **MVVM pipeline, ViewModel named functions, or direct-callback UI wiring** → [mvvm.md](references/mvvm.md)
+- **File organization, naming conventions, or disciplined screen architecture** → [clean-code.md](references/clean-code.md)
+- **Three phases, state primitives, side effects, or modifiers** → [compose-essentials.md](references/compose-essentials.md)
+- **M3 theme, dynamic color, M3 components, or adaptive layouts** → [material-design.md](references/material-design.md)
+- **AsyncImage, image cache, SVG, or Coil 3** → [image-loading.md](references/image-loading.md)
+- **LazyColumn, LazyRow, keys, grids, pager, or scroll state** → [lists-grids.md](references/lists-grids.md)
+- **Nav 2 vs Nav 3 decision or MVI navigation rules** → [navigation.md](references/navigation.md)
+- **Loading states, skeleton/shimmer, or inline validation UX** → [ui-ux.md](references/ui-ux.md)
+- **Turbine, ViewModel tests, Macrobenchmark, or lean test matrix** → [testing.md](references/testing.md)
+- **DataStore Preferences, Typed DataStore, or KMP DataStore** → [datastore.md](references/datastore.md)
+- **Room entities, DAOs, migrations, relationships, or Room MVI integration** → [room-database.md](references/room-database.md)
+- **Ktor `@Resource` routes or type-safe API definitions** → [networking-ktor.md](references/networking-ktor.md) § Type-Safe Resources
+- **MockEngine, network testing, or Koin/Hilt network DI** → [networking-ktor-testing.md](references/networking-ktor-testing.md)
+- **Koin CMP setup, Nav 3 Koin integration, or scoped modules** → [koin.md](references/koin.md)
+- **Hilt Android setup, @HiltViewModel, scopes, or Hilt testing** → [hilt.md](references/hilt.md)
+- **commonMain sharing, expect/actual, or platform bridges** → [cross-platform.md](references/cross-platform.md)
+- **CMP Res class, qualifiers, localization, or Android resource interop** → [resources.md](references/resources.md)
+- **AGP 9+, version catalog, convention plugins, or composite builds** → [gradle-build.md](references/gradle-build.md)
+- **GitHub Actions CI/CD, desktop packaging, signing, or notarization** → [ci-cd-distribution.md](references/ci-cd-distribution.md)
+
+## Validation
+
+Run `./scripts/validate.sh` to scan the skill package against the [agentskills.io spec](https://agentskills.io/specification). It checks token budgets, broken links, file structure, and content quality. Fix any errors before committing.

+ 195 - 0
.claude/skills/compose-skill/references/accessibility.md

@@ -0,0 +1,195 @@
+# Accessibility
+
+## Content Descriptions
+
+Every `Image` and `Icon` composable must have an explicit `contentDescription`:
+
+- **Decorative** (no information conveyed): `contentDescription = null`
+- **Meaningful** (conveys information): localized string via `stringResource()`
+
+```kotlin
+// Decorative — purely visual, screen reader skips it
+Icon(Icons.Default.Star, contentDescription = null)
+
+// Meaningful — screen reader announces it
+Image(
+    painter = painterResource(Res.drawable.profile_avatar),
+    contentDescription = stringResource(Res.string.user_avatar_description)
+)
+```
+
+Flag any `Image` with a non-obvious resource name and `contentDescription = null` that lacks a comment explaining why it is decorative.
+
+## Semantics API
+
+Use `Modifier.semantics { }` to add or override accessibility information.
+
+| Property | Purpose | Example values |
+|---|---|---|
+| `contentDescription` | Override screen reader announcement | `"Profile picture of $name"` |
+| `role` | Declare interactive role | `Role.Button`, `Role.Image`, `Role.Switch`, `Role.Tab`, `Role.RadioButton`, `Role.Checkbox` |
+| `stateDescription` | Describe current state | `"Expanded"`, `"Selected"`, `"3 of 5"` |
+| `heading` | Mark as section heading | `heading()` |
+
+```kotlin
+Box(
+    modifier = Modifier.semantics {
+        contentDescription = "Profile picture of ${user.name}"
+        role = Role.Image
+    }
+)
+```
+
+Prefer built-in Material components (`Button`, `Switch`, `Checkbox`) over manual `role` assignment — they include correct semantics automatically.
+
+## Grouping and Overriding Semantics
+
+### mergeDescendants
+
+Groups a composable's children into a single screen reader announcement. Use when children together form one logical unit.
+
+```kotlin
+// GOOD — screen reader announces "4.5 stars, 128 reviews" as one item
+Row(modifier = Modifier.semantics(mergeDescendants = true) { }) {
+    Icon(Icons.Default.Star, contentDescription = null)
+    Text("4.5 stars")
+    Text("(128 reviews)")
+}
+```
+
+```kotlin
+// BAD — screen reader stops on each child separately, fragmenting the meaning
+Row {
+    Icon(Icons.Default.Star, contentDescription = "Star icon")
+    Text("4.5 stars")
+    Text("(128 reviews)")
+}
+```
+
+### clearAndSetSemantics
+
+Replaces all auto-generated and child semantics with a single custom description. Use when the auto-generated text is verbose or misleading.
+
+```kotlin
+Row(modifier = Modifier.clearAndSetSemantics {
+    contentDescription = "Rating: 4.5 stars from 128 reviews"
+}) {
+    StarRating(4.5f)
+    Text("(128 reviews)")
+}
+```
+
+| Need | Use |
+|---|---|
+| Group children into one announcement, keep their text | `semantics(mergeDescendants = true)` |
+| Replace all child semantics with a custom string | `clearAndSetSemantics { }` |
+
+## Touch Targets
+
+Minimum interactive size: **48 x 48 dp**.
+
+- Use `Modifier.minimumInteractiveComponentSize()` on custom interactive elements to enforce this automatically.
+- Material components (`Button`, `IconButton`, `Switch`, etc.) handle this internally — do not add redundant padding.
+
+```kotlin
+// Custom clickable element — enforce minimum touch target
+Box(
+    modifier = Modifier
+        .minimumInteractiveComponentSize()
+        .clickable { onAction() }
+) {
+    Icon(Icons.Default.Add, contentDescription = "Add item")
+}
+```
+
+## Color and Contrast
+
+WCAG AA minimum contrast ratios:
+
+| Text type | Minimum ratio |
+|---|---|
+| Normal text (<18sp) | 4.5 : 1 |
+| Large text (18sp+ or 14sp bold+) | 3 : 1 |
+
+Never use color as the **only** way to convey information. Always pair with an icon, text label, or pattern.
+
+```kotlin
+// BAD — only color differentiates status
+Box(modifier = Modifier.background(if (isOnline) Color.Green else Color.Red))
+
+// GOOD — icon + text + color
+Row {
+    Icon(
+        imageVector = if (isOnline) Icons.Default.CheckCircle else Icons.Default.Cancel,
+        contentDescription = null,
+    )
+    Text(if (isOnline) "Online" else "Offline")
+}
+```
+
+Use theme tokens (`MaterialTheme.colorScheme`) rather than hardcoded colors — theme tokens are designed to meet contrast requirements across light/dark modes.
+
+## Custom Interactive Elements
+
+When using `Modifier.clickable` on a non-Button composable, add semantic role and click label:
+
+```kotlin
+Card(
+    modifier = Modifier
+        .clickable(onClickLabel = "Open book details") { onBookClick(book.id) }
+        .semantics { role = Role.Button }
+) {
+    Text(book.title)
+}
+```
+
+Prefer `Button` / `IconButton` / `TextButton` over custom clickable elements when possible — they include correct semantics, touch targets, and visual feedback out of the box.
+
+## Custom Accessibility Actions
+
+For composables with multiple actions (e.g., a list item with favorite, share, delete), expose named accessibility actions so screen reader users can discover and invoke them without navigating to individual buttons:
+
+```kotlin
+Modifier.semantics {
+    customActions = listOf(
+        CustomAccessibilityAction("Add to favorites") { onFavorite(); true },
+        CustomAccessibilityAction("Share") { onShare(); true },
+    )
+}
+```
+
+The lambda returns `true` if the action was handled successfully.
+
+## MVI Integration
+
+Accessibility does not change the MVI architecture. Key placement rules:
+
+| Concern | Where | Why |
+|---|---|---|
+| Semantic descriptions (`contentDescription`, `stateDescription`) | Screen / Leaf composables | These are UI-layer concerns — resolve from state close to rendering |
+| Semantic keys/enums for dynamic descriptions | `State` data class | e.g., `statusLabel: StringKey` — the UI resolves to a localized string |
+| `Modifier.semantics` | Composable `modifier` chains | Applied in the UI layer, never in ViewModel |
+| Accessibility-triggered actions (e.g., custom action callbacks) | `onEvent` callbacks → ViewModel | Same as any user interaction — goes through the event pipeline |
+
+Keep accessibility descriptions in the **UI layer**, not in state. State holds semantic keys (enums, string resource keys); the Screen/Leaf composable resolves them to localized strings via `stringResource()`.
+
+## Do / Don't
+
+### Do
+
+- Provide `contentDescription` for every meaningful `Image` and `Icon`
+- Use `mergeDescendants` for logically grouped content
+- Use `clearAndSetSemantics` when auto-generated text is misleading
+- Enforce 48dp minimum touch targets on custom interactive elements
+- Pair color with icons/text for status indicators
+- Use `MaterialTheme.colorScheme` tokens for contrast-safe colors
+- Test with a screen reader on each target platform
+
+### Don't
+
+- Leave `contentDescription = null` on meaningful images without a comment
+- Apply `role` manually when a Material component already provides it
+- Add extra padding on Material components that already meet touch target requirements
+- Rely on color alone to communicate state changes
+- Put localized accessibility strings in ViewModel state — use semantic keys and resolve in UI
+- Hardcode accessibility text — use `stringResource()` for localization

+ 238 - 0
.claude/skills/compose-skill/references/animations-advanced.md

@@ -0,0 +1,238 @@
+# Animations — Advanced Patterns
+
+Shared element transitions, gesture-driven animations, Canvas drawing, and graphicsLayer optimization. For core animation APIs (animate*AsState, Animatable, updateTransition, AnimatedVisibility, AnimatedContent, AnimationSpec) and the animation API decision table, see [animations.md](animations.md).
+
+## Shared Element Transitions
+
+Seamless transitions between composables that share visual content (e.g., list item -> detail screen). Available in both Jetpack Compose and Compose Multiplatform (since CMP 1.7+).
+
+### Core setup
+
+```kotlin
+SharedTransitionLayout {
+    AnimatedContent(showDetails, label = "shared") { targetState ->
+        if (!targetState) {
+            ListItem(
+                sharedTransitionScope = this@SharedTransitionLayout,
+                animatedVisibilityScope = this@AnimatedContent,
+            )
+        } else {
+            DetailScreen(
+                sharedTransitionScope = this@SharedTransitionLayout,
+                animatedVisibilityScope = this@AnimatedContent,
+            )
+        }
+    }
+}
+```
+
+### sharedElement vs sharedBounds
+
+| | `sharedElement` | `sharedBounds` |
+|---|---|---|
+| Content | Same content in both states | Visually different content |
+| Rendering | Only target content rendered during transition | Both entering and exiting content visible |
+| Use for | Hero transitions (same image/icon) | Container transforms (card -> full screen) |
+| Text | Avoid (use `sharedBounds`) | Preferred (handles font changes) |
+
+### Modifier usage
+
+```kotlin
+Image(
+    modifier = Modifier.sharedElement(
+        rememberSharedContentState(key = "image-$id"),
+        animatedVisibilityScope = animatedVisibilityScope,
+    )
+)
+
+Box(
+    modifier = Modifier.sharedBounds(
+        rememberSharedContentState(key = "bounds-$id"),
+        animatedVisibilityScope = animatedVisibilityScope,
+        enter = fadeIn(), exit = fadeOut(),
+        resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds(),
+    )
+)
+```
+
+### Unique keys
+
+```kotlin
+data class SharedElementKey(val id: Long, val origin: String, val type: SharedElementType)
+enum class SharedElementType { Bounds, Image, Title, Background }
+```
+
+### Customize transitions
+
+```kotlin
+Modifier.sharedElement(
+    state = rememberSharedContentState(key = "image"),
+    animatedVisibilityScope = scope,
+    boundsTransform = BoundsTransform { initial, target ->
+        keyframes {
+            durationMillis = 300
+            initial at 0 using ArcMode.ArcBelow using FastOutSlowInEasing
+            target at 300
+        }
+    },
+)
+```
+
+### resizeMode
+
+- `ScaleToBounds()` — scales child layout graphically. Recommended for `Text`.
+- `RemeasureToBounds` — re-measures child each frame. Recommended for different aspect ratios.
+
+### With Navigation
+
+Wrap `NavHost` in `SharedTransitionLayout`. Pass both scopes to screens:
+
+```kotlin
+SharedTransitionLayout {
+    NavHost(navController, startDestination = "list") {
+        composable("list") {
+            ListScreen(this@SharedTransitionLayout, this@composable)
+        }
+        composable("detail/{id}") {
+            DetailScreen(this@SharedTransitionLayout, this@composable)
+        }
+    }
+}
+```
+
+### Async images (Coil)
+
+For full Coil 3 guidance (API choice, caching strategy, SVG, and CMP resource loading), see [Image Loading](image-loading.md).
+
+```kotlin
+AsyncImage(
+    model = ImageRequest.Builder(LocalPlatformContext.current)
+        .data(url)
+        .placeholderMemoryCacheKey("image-$id")
+        .memoryCacheKey("image-$id")
+        .build(),
+    modifier = Modifier.sharedElement(
+        rememberSharedContentState(key = "image-$id"),
+        animatedVisibilityScope = scope,
+    ),
+)
+```
+
+### Overlays and clipping
+
+- `renderInSharedTransitionScopeOverlay()` — keep elements (bottom bar, FAB) on top during transition
+- `clipInOverlayDuringTransition` — clip shared element to parent bounds
+- `skipToLookaheadSize()` — prevent text reflow during size transitions
+
+### Modifier order
+
+Size modifiers AFTER `sharedElement()`. Inconsistent modifier order between matched elements causes visual jumps.
+
+## Gesture-Driven Animations
+
+### Tap to animate
+
+```kotlin
+val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
+
+Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
+    coroutineScope {
+        while (true) {
+            awaitPointerEventScope {
+                val position = awaitFirstDown().position
+                launch { offset.animateTo(position) }
+            }
+        }
+    }
+}) {
+    Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
+}
+```
+
+Interruption: tapping during animation cancels current and starts new, maintaining velocity.
+
+### Swipe to dismiss
+
+```kotlin
+fun Modifier.swipeToDismiss(onDismissed: () -> Unit) = composed {
+    val offsetX = remember { Animatable(0f) }
+    pointerInput(Unit) {
+        val decay = splineBasedDecay<Float>(this)
+        coroutineScope {
+            while (true) {
+                val velocityTracker = VelocityTracker()
+                offsetX.stop()
+                awaitPointerEventScope {
+                    val pointerId = awaitFirstDown().id
+                    horizontalDrag(pointerId) { change ->
+                        launch { offsetX.snapTo(offsetX.value + change.positionChange().x) }
+                        velocityTracker.addPosition(change.uptimeMillis, change.position)
+                    }
+                }
+                val velocity = velocityTracker.calculateVelocity().x
+                val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
+                offsetX.updateBounds(-size.width.toFloat(), size.width.toFloat())
+                launch {
+                    if (targetOffsetX.absoluteValue <= size.width) {
+                        offsetX.animateTo(0f, initialVelocity = velocity)
+                    } else {
+                        offsetX.animateDecay(velocity, decay)
+                        onDismissed()
+                    }
+                }
+            }
+        }
+    }.offset { IntOffset(offsetX.value.roundToInt(), 0) }
+}
+```
+
+Key patterns: `snapTo` during drag (sync with finger), `animateDecay` for fling, `animateTo(0f)` for snap-back, `VelocityTracker` for fling velocity.
+
+## Canvas and Custom Drawing
+
+### Canvas composable
+
+```kotlin
+Canvas(modifier = Modifier.fillMaxSize()) {
+    drawCircle(color = Color.Blue, radius = 100f, center = center)
+    drawRect(color = Color.Red, topLeft = Offset(50f, 50f), size = Size(200f, 200f))
+    drawLine(Color.Green, start = Offset.Zero, end = Offset(size.width, size.height), strokeWidth = 4f)
+}
+```
+
+### Drawing modifiers
+
+`Modifier.drawBehind { }` draws behind child content; `Modifier.drawWithContent { drawContent(); … }` draws over or around it.
+
+### Animate canvas content
+
+```kotlin
+val progress by animateFloatAsState(if (active) 1f else 0f, label = "progress")
+Canvas(Modifier.size(200.dp)) {
+    drawArc(Color.Blue, startAngle = -90f, sweepAngle = 360f * progress, useCenter = false, style = Stroke(8.dp.toPx()))
+}
+```
+
+Canvas draws in the Drawing phase — no recomposition needed for visual updates.
+
+## graphicsLayer for Efficient Animation
+
+`graphicsLayer` transforms at the Drawing phase level, avoiding recomposition entirely:
+
+```kotlin
+Box(modifier = Modifier.graphicsLayer {
+    scaleX = animatedScale.value
+    rotationZ = animatedRotation.value
+    alpha = animatedAlpha.value
+    translationX = animatedOffset.value
+    shadowElevation = animatedElevation.value.toPx()
+})
+```
+
+```kotlin
+// BAD: recomposes every frame
+Box(Modifier.scale(scaleX))
+
+// GOOD: transforms in draw phase
+Box(Modifier.graphicsLayer { scaleX = animatedScale.value })
+```

+ 191 - 0
.claude/skills/compose-skill/references/animations.md

@@ -0,0 +1,191 @@
+# Animations
+
+animate*AsState, Animatable, updateTransition, AnimatedVisibility, AnimatedContent, and AnimationSpec patterns. Works on all CMP targets. For shared element transitions, gesture-driven motion, and graphicsLayer, see [animations-advanced.md](animations-advanced.md).
+
+References:
+- [Choose an animation API (Android)](https://developer.android.com/develop/ui/compose/animation/choose-api)
+- [Quick guide (Android)](https://developer.android.com/develop/ui/compose/animation/quick-guide)
+
+## MVI Rules for Animation State
+
+- Animation state is **local UI state** — keep in composables, not reducers
+- Reducer state = business/UI meaning, not visual tween progress
+- Never put `buttonBounceProgress`, `errorShakeCounter`, `skeletonAlpha`, `rowRemovalAnimationPhase` in ViewModel state
+
+## Choosing the Right API
+
+| Question | API |
+|---|---|
+| SVG/icon animation? | `AnimatedVectorDrawable` (Android), Lottie/Compottie (CMP) |
+| Infinite repeat? | `rememberInfiniteTransition` |
+| Switching composables? | `AnimatedContent` or `Crossfade` |
+| Appear/disappear? | `AnimatedVisibility` |
+| Size change? | `Modifier.animateContentSize()` |
+| Multiple props together? | `updateTransition` |
+| Different timing per prop? | `Animatable` with sequential `animateTo` |
+| Single prop with target? | `animate*AsState` |
+| Gesture-driven? | `Animatable` with `animateTo`/`snapTo` |
+| List item insert/remove/reorder? | `Modifier.animateItem()` |
+
+## AnimationSpec Reference
+
+| Spec | When to use | Key detail |
+|---|---|---|
+| `spring` (default) | General purpose, interruption-safe | Maintains velocity on target change; `dampingRatio` (bounciness), `stiffness` (speed) |
+| `tween` | Need exact duration control | `durationMillis`, `delayMillis`, `easing` (`FastOutSlowInEasing`, `LinearEasing`, etc.) |
+| `keyframes` | Specific values at timestamps | `value at millis using easing` |
+| `keyframesWithSplines` | Smooth 2D curved paths | `Offset at fraction` |
+| `repeatable` / `infiniteRepeatable` | Looping | `iterations`, `repeatMode` (Reverse/Restart) |
+| `snap` | Instant jump | Optional `delayMillis` |
+
+**Prefer `spring`** — handles interruption smoothly. `tween` snaps to a new curve on interruption, which feels jarring.
+
+## animate*AsState — Single Value
+
+```kotlin
+val alpha by animateFloatAsState(if (enabled) 1f else 0.5f, label = "alpha")
+val color by animateColorAsState(if (selected) Color.Blue else Color.Gray, label = "color")
+val padding by animateDpAsState(if (expanded) 16.dp else 0.dp, label = "padding")
+val offset by animateIntOffsetAsState(if (moved) IntOffset(100, 100) else IntOffset.Zero, label = "offset")
+```
+
+Available types: `Float`, `Color`, `Dp`, `Size`, `Offset`, `Rect`, `Int`, `IntOffset`, `IntSize`. Custom types via `animateValueAsState` with `TwoWayConverter`.
+
+**Performance tips:**
+- `Modifier.drawBehind { drawRect(animatedColor) }` is more performant than `Modifier.background()` for animated colors
+- `Modifier.graphicsLayer { scaleX = scale; scaleY = scale }` for transforms — Drawing phase only
+- Set `textMotion = TextMotion.Animated` for smooth text scale transitions
+
+## Animatable — Coroutine-Based Control
+
+```kotlin
+val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
+
+LaunchedEffect(targetPosition) { offset.animateTo(targetPosition) }
+Box(Modifier.offset { offset.value.toIntOffset() })
+```
+
+| Operation | Purpose |
+|---|---|
+| `animateTo(target)` | Animate to target (suspends) |
+| `snapTo(value)` | Instant set (gesture sync) |
+| `animateDecay(velocity, decay)` | Fling deceleration |
+| `stop()` | Cancel animation |
+| `updateBounds(lower, upper)` | Constrain range |
+
+```kotlin
+// Sequential
+LaunchedEffect(Unit) {
+    alphaAnim.animateTo(1f)
+    yAnim.animateTo(100f)
+}
+
+// Concurrent
+LaunchedEffect(Unit) {
+    launch { alphaAnim.animateTo(1f) }
+    launch { yAnim.animateTo(100f) }
+}
+```
+
+New `animateTo` cancels ongoing animation and continues from current value/velocity — no jumpiness.
+
+## updateTransition — Multi-Property State Machine
+
+```kotlin
+enum class CardState { Collapsed, Expanded }
+
+val transition = updateTransition(cardState, label = "card")
+val size by transition.animateDp(label = "size") { state ->
+    when (state) { CardState.Collapsed -> 64.dp; CardState.Expanded -> 128.dp }
+}
+val color by transition.animateColor(label = "color") { state ->
+    when (state) { CardState.Collapsed -> Color.Gray; CardState.Expanded -> Color.Red }
+}
+```
+
+Per-transition timing: `transitionSpec = { when { Expanded isTransitioningTo Collapsed -> spring(stiffness = 50f); else -> tween(500) } }`.
+
+Start immediately: `MutableTransitionState(Collapsed).apply { targetState = Expanded }`.
+
+Coordinated children: `transition.AnimatedVisibility(visible = { it == Expanded }) { ... }` and `transition.AnimatedContent { ... }`.
+
+## rememberInfiniteTransition
+
+Shimmer, pulsing indicators, loading spinners:
+
+```kotlin
+val infiniteTransition = rememberInfiniteTransition(label = "infinite")
+val alpha by infiniteTransition.animateFloat(
+    initialValue = 0.3f, targetValue = 1f,
+    animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
+    label = "alpha",
+)
+```
+
+## AnimatedVisibility
+
+```kotlin
+AnimatedVisibility(
+    visible = isVisible,
+    enter = fadeIn() + slideInVertically { -40.dp.roundToPx() },
+    exit = slideOutVertically() + fadeOut(),
+) { Text("Hello") }
+```
+
+| Enter | Exit |
+|---|---|
+| `fadeIn` | `fadeOut` |
+| `slideIn` / `slideInHorizontally` / `slideInVertically` | `slideOut` / `slideOutHorizontally` / `slideOutVertically` |
+| `scaleIn` | `scaleOut` |
+| `expandIn` / `expandHorizontally` / `expandVertically` | `shrinkOut` / `shrinkHorizontally` / `shrinkVertically` |
+
+Combine with `+`. Per-child: `Modifier.animateEnterExit(enter = ..., exit = ...)`. Use `EnterTransition.None`/`ExitTransition.None` on parent to let children define their own.
+
+## AnimatedContent
+
+```kotlin
+AnimatedContent(
+    targetState = uiState,
+    transitionSpec = {
+        if (targetState > initialState)
+            slideInVertically { it } + fadeIn() togetherWith slideOutVertically { -it } + fadeOut()
+        else
+            slideInVertically { -it } + fadeIn() togetherWith slideOutVertically { it } + fadeOut()
+        using SizeTransform(clip = false)
+    },
+    label = "content",
+) { target ->
+    when (target) {
+        UiState.Loading -> LoadingScreen()
+        UiState.Success -> SuccessScreen()
+        UiState.Error -> ErrorScreen()
+    }
+}
+```
+
+`SizeTransform` controls size animation between states. Always use the lambda parameter (`target`), not the outer variable.
+
+## Performance Rules
+
+- `spring` as default — handles interruption, physically natural
+- `Modifier.offset { }` (lambda) defers to Layout phase
+- `graphicsLayer { }` for visual transforms — Drawing phase only, cheapest
+- `drawBehind` for animated colors instead of `background()`
+- `animateContentSize` BEFORE size modifiers in chain
+- In `AnimatedContent`/`AnimatedVisibility`: use lambda parameter, not outer variable
+
+## Anti-Patterns
+
+| Anti-pattern | Why | Fix |
+|---|---|---|
+| Animation state in ViewModel | Pollutes business state | Local `animate*AsState` or `Animatable` |
+| `Modifier.scale()`/`.offset()` | Recomposition every frame | `graphicsLayer { scaleX = ...; translationX = ... }` |
+| Animating every change | Jittery UI | Animate meaningful transitions only |
+| `animateContentSize` after size modifiers | No effect | Place BEFORE `size`/`fillMaxWidth` |
+| Outer variable in AnimatedContent | Stale during exit | Use lambda parameter |
+| `tween`/`snap` everywhere | Jarring interruption | Prefer `spring` |
+| Animating padding/size every frame | Expensive Layout phase | Prefer `graphicsLayer` transforms |
+
+## Advanced Patterns
+
+For shared element transitions, gesture-driven animations, Canvas, and graphicsLayer optimization, see [animations-advanced.md](animations-advanced.md).

+ 109 - 0
.claude/skills/compose-skill/references/anti-patterns.md

@@ -0,0 +1,109 @@
+# Anti-Patterns
+
+Quick-reference table of cross-cutting patterns that hurt MVI Compose Multiplatform codebases. Domain-specific anti-patterns (navigation, networking, paging, DI, etc.) live in their respective reference files — see the "Detailed in" column.
+
+For overengineering patterns (bloated base classes, unnecessary use cases, 4-type MVI), see [clean-code.md](clean-code.md).
+
+## Cross-Cutting Anti-Patterns
+
+| Anti-pattern | Why it is harmful | Better replacement | Detailed in |
+|---|---|---|---|
+| Business logic inside composables | forks source of truth, hurts testability, reruns during composition | move logic into ViewModel/domain services | [architecture.md](architecture.md) |
+| Giant god-ViewModel | blast radius too large, slow reasoning, hard ownership | one ViewModel per screen or independent flow | [architecture.md](architecture.md) |
+| Scattered `updateState`/`sendEffect` with no structure | state transitions hard to trace, mutations across callbacks | disciplined `onEvent()` as single entry point | [clean-code.md](clean-code.md) |
+| Unstable state models (mutable collections, lambdas in state) | defeats Compose skipping, more recomposition | immutable data classes, immutable collections | [performance.md](performance.md) |
+| Duplicated derived data (`total`, `formattedTotal`, `hasTotal` all stored) | bugs from drift, harder transitions | keep canonical value + derive via computed property | [architecture.md](architecture.md) |
+| Broad state reads in parent composables | recomposition cascades to all children | slice state, pass only required props to each child | [performance.md](performance.md) |
+| Mutable state passed deep into tree | hidden writes, unpredictable data flow | explicit props + callbacks | [compose-essentials.md](compose-essentials.md) |
+| One-off events stored as consumable state (`showSnackbarOnce = true`) | event replay on config change, stale effects | separate `Effect` via `Channel` | [architecture.md](architecture.md) |
+| No-op state emissions (copy state when nothing changed) | wasted recomposition cycles | guard unchanged values before updating | [performance.md](performance.md) |
+| Full-screen loading wipes existing content | bad UX, layout jumps, lost user trust | keep old content + inline refresh indicator | [ui-ux.md](ui-ux.md) |
+| ViewModel doing platform work directly (share, analytics, navigation) | breaks testability, platform coupling | emit effects, handle in Route composable | [architecture.md](architecture.md) |
+| Animation state in ViewModel for no reason (`shakeCount`, `alpha`) | pollutes business state | local composable animation state | [animations.md](animations.md) |
+| Display strings stored too early (ViewModel emits pre-baked formatted text) | locale inflexibility, state duplication, harder reuse | keep canonical values until presentation boundary | [architecture.md](architecture.md) |
+| Poor lazy list keys (no key or index-based) | state jumps between rows, broken animations | stable key by domain ID | [lists-grids.md](lists-grids.md) |
+| Too many trivial composables (wrappers around single `Text`/`Spacer`) | fragmentation, harder reading | extract only meaningful boundaries | [clean-code.md](clean-code.md) |
+| Platform abstraction too early (interfaces for everything before pain) | unnecessary indirection, poor fit | share business logic first, abstract real platform capabilities only | [cross-platform.md](cross-platform.md) |
+| Forcing MVI migration on existing codebase | churn without value, team friction | respect existing patterns, introduce MVI for new features only | [clean-code.md](clean-code.md) |
+| Inline fully qualified package paths | hurts readability, clutters business logic, hides intent behind package noise | import at file top; use `import ... as ...` for name clashes | [clean-code.md](clean-code.md) |
+
+## Examples
+
+### Business logic inside composables
+
+```kotlin
+// BAD — logic in composable; untestable, reruns on every recomposition
+@Composable
+fun CheckoutScreen(viewModel: CheckoutViewModel) {
+    val state by viewModel.state.collectAsStateWithLifecycle()
+    val total = state.items.sumOf { it.price * it.qty } // business logic here
+    val tax = total * 0.08
+    Text("Total: $${"$"}total  Tax: $${"$"}tax")
+}
+
+// GOOD — derive in ViewModel/state, composable only renders
+data class CheckoutState(
+    val items: List<LineItem> = emptyList(),
+    val total: Double = 0.0,
+    val tax: Double = 0.0,
+)
+
+@Composable
+fun CheckoutScreen(state: CheckoutState, onEvent: (CheckoutEvent) -> Unit) {
+    Text("Total: ${state.total}  Tax: ${state.tax}")
+}
+```
+
+### One-off events as consumable state booleans
+
+```kotlin
+// BAD — event replays on config change, race between read and reset
+data class UiState(val showSnackbar: Boolean = false)
+
+LaunchedEffect(state.showSnackbar) {
+    if (state.showSnackbar) {
+        snackbarHostState.showSnackbar("Saved")
+        viewModel.onEvent(DismissSnackbar)   // consumer must remember to reset
+    }
+}
+
+// GOOD — Channel delivers exactly once, survives config change
+sealed interface Effect { data class ShowSnackbar(val msg: String) : Effect }
+
+CollectEffect(viewModel.effects) { effect ->
+    when (effect) {
+        is Effect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.msg)
+    }
+}
+```
+
+## Domain-Specific Anti-Patterns
+
+These reference files contain their own anti-pattern sections with detailed BAD/GOOD code examples:
+
+| Domain | Reference | What it covers |
+|---|---|---|
+| Architecture & MVI | [architecture.md](architecture.md) | event handling, state modeling, effect misuse, domain layer violations |
+| Overengineering | [clean-code.md](clean-code.md) | bloated base classes, 4-type MVI, use case wrappers, naming |
+| Coroutines & Flow | [coroutines-flow.md](coroutines-flow.md) | GlobalScope, blocking dispatchers, unbound scopes, stateIn misuse |
+| Performance | [performance.md](performance.md) | recomposition, stability, state shape, read boundaries |
+| Compose Essentials | [compose-essentials.md](compose-essentials.md) | side effects, modifier ordering, CompositionLocal |
+| Animations | [animations.md](animations.md) | ViewModel animation state, graphicsLayer misuse, over-animating |
+| Lists & Grids | [lists-grids.md](lists-grids.md) | keys, nested scrolling, contentType |
+| UI/UX | [ui-ux.md](ui-ux.md) | disappearing content, layout jumps, loading states |
+| Navigation (shared) | [navigation.md](navigation.md) | MVI navigation rules, anti-patterns for both Nav 2 and Nav 3 |
+| Paging | [paging-mvi-testing.md](paging-mvi-testing.md) | PagingData in UiState, key misuse, LoadState handling |
+| Networking | [networking-ktor-testing.md](networking-ktor-testing.md) | MockEngine, DI integration, testing anti-patterns |
+| Network Architecture | [networking-ktor-architecture.md](networking-ktor-architecture.md) | plugin composition, error strategy, client lifecycle, result wrapper choice |
+| Room Database | [room-database.md](room-database.md) | entity design, DAO patterns, migrations |
+| DataStore | [datastore.md](datastore.md) | singleton enforcement, blocking reads, corruption |
+| DI (Koin) | [koin.md](koin.md) | module organization, scoping, ViewModel injection |
+| DI (Hilt) | [hilt.md](hilt.md) | module structure, scoping, testing |
+| Image Loading | [image-loading.md](image-loading.md) | cache policy, transformations, placeholder usage |
+| Testing | [testing.md](testing.md) | missing ViewModel tests, mocking DI, testing internals |
+| Cross-Platform | [cross-platform.md](cross-platform.md) | expect/actual misuse, premature abstraction |
+| iOS Interop | [ios-swift-interop.md](ios-swift-interop.md) | naming, nullability, Flow bridging |
+| Resources | [resources.md](resources.md) | Android R vs CMP Res, qualifier usage |
+| Material Design | [material-design.md](material-design.md) | theme setup, component choice, adaptive layouts |
+| Accessibility | [accessibility.md](accessibility.md) | missing semantics, touch targets, contrast |
+| Gradle & Build | [gradle-build.md](gradle-build.md) | hardcoded versions, buildSrc, convention plugin timing |

+ 204 - 0
.claude/skills/compose-skill/references/architecture.md

@@ -0,0 +1,204 @@
+# Architecture & State Management
+
+Shared architecture concepts for MVI and MVVM. Load first for architecture questions, then see [mvi.md](mvi.md) or [mvvm.md](mvvm.md) for pattern-specific details.
+
+Preservation rule: if the project already has a coherent screen architecture pattern (MVI, MVVM, or variant), preserve it unless the user explicitly asks to migrate or the current pattern cannot satisfy a required constraint.
+
+## Source of Truth
+
+Per screen:
+
+- **Screen behavior:** `StateFlow<ScreenState>` owned by the screen state holder, often a ViewModel
+- **Persisted data:** repository / database / remote service
+- **Local visual-only concerns:** local Compose state in the route or leaf composable
+
+Do not mix them.
+
+## Choosing a State Owner
+
+| Situation | Default owner | Why |
+|---|---|---|
+| Visual state for one composable subtree | Local Compose state | Smallest scope, easiest reuse |
+| Complex UI logic, no business/data responsibilities | Plain state holder class | Testable without ViewModel |
+| Screen-level business rules, async, persistence, effects | ViewModel | Lifecycle integration, screen state ownership |
+
+A ViewModel is one implementation of a screen state holder, not a requirement for every composable.
+
+## MVI vs MVVM Decision Guide
+
+Both use unidirectional data flow with `StateFlow<State>` and `Channel<Effect>`. The difference is how UI actions reach the ViewModel.
+
+| Criterion | MVI | MVVM |
+|---|---|---|
+| UI-to-VM contract | `sealed interface Event` + `onEvent()` | Named public functions |
+| Boilerplate | Higher (sealed class + when) | Lower (direct calls) |
+| Testing input | Single `onEvent()` entry point | Multiple function entry points |
+| Best for | Many events, event logging, analytics | Simpler screens, less ceremony |
+
+**Choose MVI when:** project uses MVI, many user actions to enumerate, need exhaustive event contracts.
+**Choose MVVM when:** project uses MVVM, few actions, team prefers direct function calls.
+**Default:** preserve the project's existing pattern.
+
+## When to Use Lighter Patterns
+
+- Purely presentational leaf composables
+- Small screens with trivial local state and no async/persistence
+- Prototypes unless user asks to formalize
+- Do not invent reducers, result types, or global frameworks unless they earn their keep
+
+## Domain Layer
+
+Pure business logic. Zero platform dependencies — runs in `commonTest` without emulators.
+
+| Rule | Rationale |
+|---|---|
+| Zero platform imports | Testable anywhere, shareable |
+| Domain models ≠ DTOs or entities | Decouples from API/DB schema |
+| Repository interfaces in domain, impls in data | Dependency inversion |
+| Mappers at data boundary | Domain ignores serialization (see [networking-ktor.md](networking-ktor.md)) |
+| Use cases only for multi-step orchestration | Don't wrap single repo calls |
+
+```kotlin
+data class Item(val id: String, val name: String, val status: ItemStatus)
+
+interface ItemRepository {
+    suspend fun getById(id: String): Item?
+    suspend fun save(item: Item)
+}
+
+class CreateItemUseCase(private val repository: ItemRepository, private val validator: ItemValidator) {
+    suspend operator fun invoke(name: String, status: ItemStatus): Result<Item> {
+        val errors = validator.validate(name)
+        if (errors.isNotEmpty()) return Result.failure(ValidationException(errors))
+        val item = Item(id = uuid(), name = name.trim(), status = status)
+        repository.save(item)
+        return Result.success(item)
+    }
+}
+```
+
+## Inter-Feature Communication
+
+| Need | Pattern | Why |
+|---|---|---|
+| React to event from another feature | Event bus (`SharedFlow`) | Fire-and-forget, many listeners |
+| Navigate to another feature | Feature API contract (`:api` module) | Type-safe, no impl dependency |
+| Pass data back | Feature API + callback | Structured return, testable |
+| Shared data stream (current user) | Shared repository in `core` | Persistent state, not one-shot |
+
+**Anti-patterns:** importing another feature's ViewModel, global "god event bus" with 50 events, cross-feature data via `CompositionLocal`.
+
+For the full api/impl split pattern, see [navigation-3-di.md](navigation-3-di.md) Modularization section.
+
+## Module Dependency Rules
+
+```text
+app -> feature:*:impl, feature:*:api, core:*
+feature:*:impl -> feature:*:api (any feature), core:*
+feature:*:api -> core:designsystem (route types only)
+core:data -> core:network, core:database, core:datastore
+```
+
+| Forbidden | Why |
+|---|---|
+| `feature:impl` → another `feature:impl` | Circular risk |
+| `feature:api` → any `feature` | API contracts must be leaf dependencies |
+| `core:*` → `feature:*` or `app` | Core cannot depend on consumers |
+| Domain → Data layer | Domain declares interfaces, data implements |
+
+## State Modeling for Forms and Calculators
+
+Split into four buckets:
+
+1. **Editable input** — raw text/choice values as the user edits
+2. **Derived/computed** — parsed, validated, calculated values
+3. **Persisted snapshot** — existing saved entity for dirty tracking
+4. **Transient UI-only** — only when purely visual and not business-significant
+
+| Concern | Where | Example |
+|---|---|---|
+| Raw field text | `state` | `"12"`, `"12."`, `""` |
+| Parsed value | computed property or `state` | `val amount get() = amountText.toDoubleOrNull()` |
+| Validation | `state.errors` | `mapOf("area" to "Required")` |
+| Calculated totals | `state` or computed | subtotal, tax |
+| Loading/refresh | `state` flags | `isSaving`, `isLoading` |
+| One-off commands | `Effect` via Channel | snackbar, navigate |
+| Scroll/focus/animation | local Compose state | `LazyListState`, expansion toggle |
+
+Use computed properties for trivial derivations:
+
+```kotlin
+data class CreateItemState(
+    val title: String = "",
+    val amount: String = "",
+    val isSaving: Boolean = false,
+    val errors: Map<String, String> = emptyMap()
+) {
+    val canSave: Boolean get() = title.isNotBlank() && amount.isNotBlank()
+    val hasErrors: Boolean get() = errors.isNotEmpty()
+}
+```
+
+**Avoid duplicated state:** don't store `total` + `formattedTotal` + `totalText`, or `showErrorDialog` + `pendingError` when one implies the other.
+
+## Where Logic Belongs
+
+| Logic | Where |
+|---|---|
+| Validation | ViewModel/domain — never in composable body |
+| Calculations | Pure calculator/domain service called by ViewModel |
+| Async orchestration | ViewModel — launch/cancel, debounce, ignore stale |
+| Side effects | ViewModel via `Effect` or `viewModelScope.launch` |
+| Local UI state | Composable — `LazyListState`, focus, animation, expansion, tooltip |
+
+Not acceptable in composables: validation, derived totals, data loading, submit enablement, business decisions.
+
+## Effect Delivery
+
+`Channel<Effect>(Channel.BUFFERED)` with `receiveAsFlow()` — default for single-consumer effects. Buffers for reliable delivery, single consumer, no replay. `SharedFlow(replay=0)` acceptable for truly fire-and-forget signals. Preserve existing `SharedFlow` effect mechanism when consistent.
+
+## Reactive Data Collection
+
+```kotlin
+private fun collectData() {
+    viewModelScope.launch {
+        repository.observe()
+            .catch { sendEffect(ShowError(it.message ?: "Load failed")) }
+            .collect { data -> updateState { copy(items = data, isLoading = false) } }
+    }
+}
+```
+
+Room and DataStore `Flow` queries auto-re-emit on changes. Map data-layer types to domain models at the repository boundary.
+
+## State Collection and Slicing
+
+**Default:** collect whole screen state once at the route boundary, slice downward.
+
+- `Route` collects `StateFlow<ScreenState>`
+- `Screen` receives `ScreenState`
+- Leaves receive **only what they need**
+- Do **not** make leaves observe the ViewModel directly
+
+### Callbacks at Boundaries
+
+- MVI: `onEvent(Event)` at route/screen boundary; leaves prefer specific callbacks
+- MVVM: individual callbacks at screen boundary; same narrowing for leaves
+- Reusable components must not know your event contract or ViewModel type
+
+## Adapting to Existing Projects
+
+| Project has | Action |
+|---|---|
+| MVI with base class (`MviHost`, `BaseViewModel`) | Use it. Don't introduce competing base. See [mvi.md](mvi.md) |
+| MVVM without strict MVI | Preserve it. Match conventions. See [mvvm.md](mvvm.md) |
+| Plain state holder classes | Valid. Only move to ViewModel when screen needs async/persistence/lifecycle |
+| 4-type MVI (Event, Result, State, Effect) | Use `Result` as project expects. Don't strip out |
+| No architecture | Choose MVI or MVVM per guide above. Trivial screens: local state is fine |
+
+## Scaling Notes
+
+- Small screens: one file for contract + ViewModel
+- Medium: split contract, ViewModel, screen, route
+- Large: extract calculation, validation, formatting into dedicated collaborators
+- Do **not** create nested state holders for every card/section by default — only when independent lifecycle, async, tests, and real reuse justify it

+ 290 - 0
.claude/skills/compose-skill/references/ci-cd-distribution.md

@@ -0,0 +1,290 @@
+# CI/CD & Distribution
+
+CI/CD and native distribution for Compose Multiplatform: Android, Desktop (JVM), and iOS.
+
+## 1. Distribution Overview
+
+| Platform | Output | Gradle Task | Notes |
+|----------|--------|-------------|-------|
+| Android | APK/AAB | `assembleRelease`/`bundleRelease` | Standard distribution |
+| Desktop macOS | DMG | `packageDmg` | Needs signing for Gatekeeper |
+| Desktop Windows | MSI | `packageMsi` | Optional signing |
+| Desktop Linux | DEB | `packageDeb` | Package manager format |
+| iOS | .app/.ipa | Xcode Archive | Gradle builds framework only |
+
+## 2. GitHub Actions — Android
+
+```yaml
+name: Android Build
+
+on:
+  push:
+    branches: [main]
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-java@v4
+        with:
+          java-version: '21'
+          distribution: 'temurin'
+      - uses: gradle/actions/setup-gradle@v4
+      
+      - run: ./gradlew :androidApp:assembleRelease
+      
+      - uses: actions/upload-artifact@v4
+        with:
+          name: android-apk
+          path: androidApp/build/outputs/apk/release/*.apk
+```
+
+### With Signing
+
+```yaml
+- name: Decode Keystore
+  run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > release.keystore
+
+- run: ./gradlew :androidApp:assembleRelease
+  env:
+    KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
+    KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+    KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+```
+
+## 3. GitHub Actions — Desktop Multi-Platform
+
+```yaml
+name: Desktop Build
+
+on:
+  workflow_dispatch:
+    inputs:
+      build_macos: { type: boolean, default: true }
+      build_windows: { type: boolean, default: true }
+      build_linux: { type: boolean, default: true }
+
+jobs:
+  build-macos:
+    if: ${{ inputs.build_macos }}
+    runs-on: macos-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-java@v4
+        with: { java-version: '21', distribution: 'temurin' }
+      - uses: gradle/actions/setup-gradle@v4
+      - run: ./gradlew :desktopApp:packageDmg
+      - uses: actions/upload-artifact@v4
+        with:
+          name: macos-dmg
+          path: desktopApp/build/compose/binaries/main/dmg/*.dmg
+
+  build-windows:
+    if: ${{ inputs.build_windows }}
+    runs-on: windows-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-java@v4
+        with: { java-version: '21', distribution: 'temurin' }
+      - uses: gradle/actions/setup-gradle@v4
+      - run: ./gradlew :desktopApp:packageMsi
+      - uses: actions/upload-artifact@v4
+        with:
+          name: windows-msi
+          path: desktopApp/build/compose/binaries/main/msi/*.msi
+
+  build-linux:
+    if: ${{ inputs.build_linux }}
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-java@v4
+        with: { java-version: '21', distribution: 'temurin' }
+      - uses: gradle/actions/setup-gradle@v4
+      - run: ./gradlew :desktopApp:packageDeb
+      - uses: actions/upload-artifact@v4
+        with:
+          name: linux-deb
+          path: desktopApp/build/compose/binaries/main/deb/*.deb
+```
+
+## 4. Desktop App Module
+
+```kotlin
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+
+plugins {
+    alias(libs.plugins.kotlin.multiplatform)
+    alias(libs.plugins.compose.multiplatform)
+    alias(libs.plugins.compose.compiler)
+}
+
+kotlin {
+    jvm()
+    sourceSets {
+        jvmMain.dependencies {
+            implementation(compose.desktop.currentOs)
+            implementation(projects.composeApp)
+        }
+    }
+}
+
+compose.desktop {
+    application {
+        mainClass = "com.example.MainKt"
+        
+        // Required for DataStore/serialization
+        jvmArgs += listOf(
+            "--add-opens", "java.base/java.lang=ALL-UNNAMED",
+            "--add-opens", "java.base/sun.nio.ch=ALL-UNNAMED"
+        )
+        
+        nativeDistributions {
+            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
+            packageName = "MyApp"
+            packageVersion = "1.0.0"
+            modules("jdk.unsupported")
+            
+            macOS {
+                bundleID = "com.example.myapp"
+                iconFile.set(project.file("icons/icon.icns"))
+                // signing { sign.set(true); identity.set("Developer ID Application: ...") }
+            }
+            windows {
+                iconFile.set(project.file("icons/icon.ico"))
+                upgradeUuid = "YOUR-UUID"  // Keep constant across versions
+            }
+            linux {
+                iconFile.set(project.file("icons/icon.png"))
+            }
+        }
+    }
+}
+```
+
+## 5. iOS Xcode Integration
+
+iOS uses Xcode, not Gradle. Gradle builds the shared framework; Xcode embeds it.
+
+### Framework in `composeApp`
+
+```kotlin
+kotlin {
+    listOf(iosArm64(), iosSimulatorArm64()).forEach {
+        it.binaries.framework {
+            baseName = "ComposeApp"
+            isStatic = true  // Required for App Store
+        }
+    }
+}
+```
+
+### Xcode Build Phase Script
+
+Add "Run Script" before "Compile Sources":
+
+```bash
+cd "$SRCROOT/.."
+./gradlew :composeApp:embedAndSignAppleFrameworkForXcode
+```
+
+### Swift Entry Point
+
+```swift
+import SwiftUI
+import ComposeApp
+
+@main
+struct iOSApp: App {
+    init() { AppKt.doInitKoin() }
+    
+    var body: some Scene {
+        WindowGroup {
+            ComposeViewControllerRepresentable().ignoresSafeArea()
+        }
+    }
+}
+
+struct ComposeViewControllerRepresentable: UIViewControllerRepresentable {
+    func makeUIViewController(context: Context) -> UIViewController {
+        MainViewControllerKt.MainViewController()
+    }
+    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+}
+```
+
+## 6. Signing
+
+### Android
+
+```kotlin
+android {
+    signingConfigs {
+        create("release") {
+            storeFile = file("release.keystore")
+            storePassword = System.getenv("KEYSTORE_PASSWORD")
+            keyAlias = System.getenv("KEY_ALIAS")
+            keyPassword = System.getenv("KEY_PASSWORD")
+        }
+    }
+    buildTypes {
+        release { signingConfig = signingConfigs.getByName("release") }
+    }
+}
+```
+
+### macOS (Direct Distribution)
+
+```kotlin
+macOS {
+    signing {
+        sign.set(true)
+        identity.set("Developer ID Application: Your Name (TEAM_ID)")
+    }
+    notarization {
+        appleID.set("your-email@example.com")
+        password.set("@keychain:AC_PASSWORD")
+        teamID.set("YOUR_TEAM_ID")
+    }
+}
+```
+
+### iOS
+
+Handled by Xcode via `CODE_SIGN_STYLE = Automatic` and `DEVELOPMENT_TEAM`.
+
+## 7. Adding Desktop to Existing CMP Project
+
+1. Add `jvm()` target in `composeApp`:
+   ```kotlin
+   kotlin {
+       jvm()
+       sourceSets {
+           jvmMain.dependencies { /* desktop deps */ }
+       }
+   }
+   ```
+
+2. Add KSP for JVM: `add("kspJvm", libs.room.compiler)`
+
+3. Create `desktopApp` module with `compose.desktop {}` config
+
+4. Add `include(":desktopApp")` to `settings.gradle.kts`
+
+## 8. Gradle Tasks
+
+| Platform | Build | Package | Run |
+|----------|-------|---------|-----|
+| Android | `assembleRelease` | `bundleRelease` | — |
+| Desktop | `jvmJar` | `packageDmg`/`packageMsi`/`packageDeb` | `run` |
+| iOS | `compileKotlinIosArm64` | Xcode Archive | Xcode |
+
+## 9. Troubleshooting
+
+| Issue | Solution |
+|-------|----------|
+| `InaccessibleObjectException` | Add `--add-opens` JVM args |
+| "App is damaged" on macOS | Enable code signing |
+| Framework not found in Xcode | Check `FRAMEWORK_SEARCH_PATHS` |
+| Windows MSI won't upgrade | Keep `upgradeUuid` constant |

+ 210 - 0
.claude/skills/compose-skill/references/clean-code.md

@@ -0,0 +1,210 @@
+# Clean Code & Avoiding Overengineering
+
+## Disciplined vs Bloated vs Overengineered MVI
+
+### Disciplined MVI
+
+One feature ViewModel, one clear state model, one `onEvent()` function, small number of effects, explicit UI contracts, shared business logic, direct feature names.
+
+### Bloated MVI
+
+Too many tiny sealed types, every action wrapped twice, separate mapper/presenter/handler for trivial screens, verbose generic layers with little value.
+
+### Overengineered MVI
+
+Generic frameworks and base abstractions replace feature code, trivial repository calls get use-case wrappers, and 4-type MVI with mandatory pure reducers appear before screens actually need them.
+
+## Decision Rules
+
+### When an Event sealed class is enough
+
+Almost always. Use one sealed interface per feature.
+
+### When event hierarchies become excessive
+
+When you see: `UserEvent`, `UiEvent`, `SystemEvent`, `InternalEvent`, `ViewEvent`, `ActionEvent` — three wrappers before any feature logic — child components that need to know root feature events.
+
+### When to model effects separately
+
+When the action leaves the ViewModel's state-management scope: network, persistence, delay/debounce, navigation, snackbar, haptics, share, analytics. Do **not** create an effect for plain synchronous state changes.
+
+### When you need a Result/PartialState type (4th type)
+
+Rarely. Consider it only when: the same state transition is triggered by many different sources (events, async completions, WebSocket messages, push notifications) and you want to centralize all transitions in one pure function. For most screens, `onEvent()` handling state updates directly is simpler and more readable.
+
+### When a generic base ViewModel helps
+
+When you have 10+ features and the boilerplate of `MutableStateFlow` + `Channel` + `onEvent()` is genuinely repetitive. A thin base class or interface that provides `updateState()`, `sendEffect()`, and `currentState` is fine. A base class that forces `handleEvent()` + `reduce()` + `dispatch()` + `asyncAction()` is overengineering unless the entire team has agreed on it.
+
+### When a screen should have a dedicated ViewModel
+
+When the screen has: async data, multi-field editing, validation, derived calculations, navigation effects, retry/refresh flow, persistent draft/original comparison.
+
+### When a lighter state holder is enough
+
+For purely visual tab selection, local expansion, local scroll affordance, tooltip/menu visibility. That is local UI state, not architecture.
+
+### When to extract reusable UI
+
+When the component has real reuse, a stable API, and a meaningful visual/behavioral boundary. Examples: `MoneyField`, `ResultCard`, `ValidationMessage`, `SettingsToggleRow`.
+
+### When not to extract
+
+Do not extract: one-line wrappers around `Text`, wrappers that only forward modifiers, components "reusable" in theory but used once, components whose props are harder to understand than the inline code.
+
+### When a use case is useful
+
+When logic is multi-step, reused, policy-heavy, test-worthy on its own, and not just repository pass-through.
+
+### When a use case is ceremony
+
+```kotlin
+class GetSettingsUseCase(private val repository: SettingsRepository) {
+    suspend operator fun invoke() = repository.getSettings()
+}
+```
+
+That is usually ceremony.
+
+## Comparison Table
+
+| Area | Good architecture | Overengineering |
+|---|---|---|
+| ViewModel | `ProductViewModel` with `onEvent()` | `BaseMviViewModel<State, Intent, Effect, Result>` with `handleEvent()` + `reduce()` |
+| Events | one feature sealed interface | multi-layer intent taxonomy |
+| State updates | inline `updateState { copy(...) }` in `onEvent()` | separate `Result` type + pure `reduce()` function for simple screens |
+| Effects | only for impure one-shot actions | effects for trivial synchronous transitions |
+| UI | route + dumb screen + meaningful leaves | every row has its own ViewModel/presenter |
+| Use cases | used for real domain logic | one wrapper per repository call |
+| Modules | feature-first (see Module Dependency Rules in architecture.md for multi-module arrows) | giant "domain/data/presentation" package islands |
+| Platform abstractions | introduced when needed | abstracted preemptively everywhere |
+| Navigation | semantic effect + route binding | global command bus + abstract navigator hierarchy |
+| Naming | `ProductState`, `ProductEvent` | `FeatureContract.State`, `FeatureContract.Action` |
+
+### Feature-first organization
+
+**Default:** organize by feature first, then by internal layers only when needed.
+
+Good:
+
+```text
+feature-product/
+  domain/
+  data/
+  presentation/
+  ui/
+```
+
+Bad:
+
+```text
+presentation/
+  product/
+  settings/
+  history/
+domain/
+  product/
+  settings/
+  history/
+data/
+  product/
+  settings/
+  history/
+```
+
+The second form becomes a horizontal maze fast.
+
+## Naming Conventions
+
+| Concept | Recommended | Avoid |
+|---|---|---|
+| Event | `ProductEvent` | `ProductActionEventIntent` |
+| State | `ProductState` | `ProductViewState`, `Contract.State` |
+| Effect | `ProductEffect` | `ProductCommandEffectSideEffect`, `SingleLiveEvent` |
+| Contract file | `ProductContract.kt` | separate files per type for small screens |
+| ViewModel | `ProductViewModel` | `BaseProductViewModel` |
+| Route | `ProductRoute` | `ProductContainerFragmentLikeThing` |
+| Screen | `ProductScreen` | `ProductView` |
+| Leaf component | `ResultCard`, `ProductForm` | `ProductFormWidgetComponentView` |
+
+## Import Hygiene
+
+**Strict rule:** never write fully qualified package paths inline. Always import at the top of the file. Use `import ... as ...` with a descriptive alias when two types share the same simple name.
+
+### BAD — inline fully qualified name
+
+```kotlin
+val unit = com.example.app.data.db.entity.enums.WeightUnit.entries
+    .find { it.name == rawValue }
+```
+
+### GOOD — proper import
+
+```kotlin
+import com.example.app.data.db.entity.enums.WeightUnit
+
+val unit = WeightUnit.entries.find { it.name == rawValue }
+```
+
+### GOOD — import alias for name clashes
+
+```kotlin
+import com.example.app.data.db.entity.enums.WeightUnit as DbWeightUnit
+import com.example.app.domain.model.WeightUnit
+
+val dbUnit = DbWeightUnit.entries.find { it.name == rawValue }
+val domainUnit = WeightUnit.fromDb(dbUnit)
+```
+
+**Alias naming:** prefix or suffix with the distinguishing layer — `Db`, `Domain`, `Ui`, `Api`, `Dto`.
+
+## Code Examples
+
+For base ViewModel patterns (abstract class and interface + delegate), see [architecture.md](architecture.md).
+For when a thin base helps versus an overengineered stack, see Decision Rules → "When a generic base ViewModel helps."
+
+### BAD: 4-type MVI forced on every screen
+
+Event → Result mapping is 1:1 with no transformation; the `Result` type adds nothing for a simple currency picker.
+
+```kotlin
+class CurrencyViewModel : MviViewModel<CurrencyEvent, CurrencyResult, CurrencyState, CurrencyEffect>(...) {
+    override fun handleEvent(e: CurrencyEvent) = when (e) {
+        is CurrencyEvent.OnSelected -> dispatch(CurrencyResult.CurrencySelected(e.currency))
+    }
+    override fun reduce(r: CurrencyResult, s: CurrencyState) = reduce(s) {
+        when (r) {
+            is CurrencyResult.CurrencySelected -> {
+                effect(CurrencyEffect.NavigateBack(r.currency))
+                state(s.copy(selected = r.currency))
+            }
+        }
+    }
+}
+```
+
+### GOOD: same screen with 3-type MVI
+
+```kotlin
+sealed interface CurrencyEvent { data class OnSelected(val currency: Currency) : CurrencyEvent }
+data class CurrencyState(val selected: Currency? = null)
+sealed interface CurrencyEffect { data class NavigateBack(val currency: Currency) : CurrencyEffect }
+class CurrencyViewModel : ViewModel() {
+    private val _state = MutableStateFlow(CurrencyState())
+    val state = _state.asStateFlow()
+    private val _effect = Channel<CurrencyEffect>(Channel.BUFFERED)
+    val effect = _effect.receiveAsFlow()
+    fun onEvent(event: CurrencyEvent) = when (event) {
+        is CurrencyEvent.OnSelected -> {
+            _state.update { it.copy(selected = event.currency) }
+            _effect.trySend(CurrencyEffect.NavigateBack(event.currency))
+        }
+    }
+}
+```
+
+Direct, readable, testable. No intermediate type.
+
+### GOOD: MVI ViewModel with async work
+
+Full annotated example with `CreateItemViewModel` (standalone and base-class variants): see [architecture.md](architecture.md) Code Examples section.

+ 232 - 0
.claude/skills/compose-skill/references/compose-essentials.md

@@ -0,0 +1,232 @@
+# Compose Essentials
+
+Foundational Compose patterns that complement MVI architecture. Consult this when working with Compose APIs directly.
+
+## Three Phases Model
+
+Every frame consists of three phases. Understanding which phase reads state prevents unnecessary recompositions.
+
+1. **Composition** — executes composable functions, evaluates state reads. State reads here trigger recomposition of the entire scope.
+2. **Layout** — calculates size and position, runs `measure` and `layout` blocks. Can read state without triggering composition recomposition.
+3. **Drawing** — emits draw operations, runs `Canvas` and custom `DrawScope`.
+
+This is why deferred state reads via lambda modifiers work:
+
+```kotlin
+// BAD: reads in composition phase, triggers recomposition on every offset change
+Box(modifier = Modifier.offset(offsetX.dp, 0.dp))
+
+// GOOD: reads in layout phase, skips composition entirely
+Box(modifier = Modifier.offset { IntOffset(offsetX.value.toInt(), 0) })
+```
+
+Similarly, `Modifier.graphicsLayer { alpha = animatedAlpha.value }` reads state in the draw phase, avoiding recomposition for visual-only changes.
+
+## State Primitives
+
+### Primitive Specializations
+
+Use type-specific state holders to avoid boxing overhead:
+
+```kotlin
+val count = mutableIntStateOf(0)       // no boxing
+val progress = mutableFloatStateOf(0f) // no boxing
+val enabled = mutableStateOf(true)     // Boolean has no specialization
+val name = mutableStateOf("Alice")     // general-purpose
+```
+
+**Pitfall:** Using `mutableStateOf<Int>()` instead of `mutableIntStateOf()` causes unnecessary boxing on every read/write.
+
+### SnapshotStateList and SnapshotStateMap
+
+Observable collections that trigger recomposition on structural changes:
+
+```kotlin
+val items = remember { mutableStateListOf<Item>() }
+items.add(Item(1, "First"))      // triggers recomposition
+items[0] = items[0].copy(name = "Updated")  // triggers recomposition
+items[0].name = "Updated"        // does NOT trigger recomposition (in-place mutation)
+```
+
+In MVI, prefer immutable collections (`ImmutableList`) in state models. `SnapshotStateList` is acceptable for UI-local state only.
+
+### Saver for rememberSaveable
+
+Custom types require explicit `Saver` for `rememberSaveable`:
+
+```kotlin
+data class FilterState(val query: String, val category: Int)
+
+val filterSaver = Saver<FilterState, String>(
+    save = { "${it.query}:${it.category}" },
+    restore = { parts -> FilterState(parts.split(":")[0], parts.split(":")[1].toInt()) }
+)
+
+var filter by rememberSaveable(stateSaver = filterSaver) {
+    mutableStateOf(FilterState("", 0))
+}
+```
+
+In MVI, `rememberSaveable` is only for small UI-local state — screen business state belongs in the ViewModel. `rememberSaveable` is multiplatform and works in CMP `commonMain`.
+
+## Side Effects
+
+### LaunchedEffect — Coroutines Scoped to Composition
+
+Launches a coroutine tied to the composable's lifecycle. Cancelled when the key changes or composable leaves composition.
+
+```kotlin
+// Key = Unit: runs once when composable enters composition
+LaunchedEffect(Unit) { setupOnce() }
+
+// Key = specific value: reruns when value changes
+LaunchedEffect(userId) { loadUserData(userId) }
+
+// Multiple keys: reruns if ANY key changes
+LaunchedEffect(userId, postId) { loadUserAndPost(userId, postId) }
+```
+
+In MVI, `LaunchedEffect` belongs at the route level for collecting UI effects. Do not use it for business logic in leaf composables.
+
+### DisposableEffect — For Cleanup
+
+```kotlin
+DisposableEffect(lifecycle) {
+    val observer = LifecycleEventObserver { _, event -> /* handle */ }
+    lifecycle.addObserver(observer)
+    onDispose { lifecycle.removeObserver(observer) }
+}
+```
+
+Always pair registration with `onDispose` cleanup.
+
+### rememberCoroutineScope — From Event Handlers
+
+```kotlin
+val scope = rememberCoroutineScope()
+Button(onClick = { scope.launch { fetchData() } }) { Text("Fetch") }
+```
+
+In MVI, prefer dispatching events to the ViewModel instead. Use `rememberCoroutineScope` only for UI-local async work (e.g., scroll animation, snackbar).
+
+Use `rememberUpdatedState` to capture latest callback values in long-running effects without restarting them.
+
+`SideEffect { }` runs after every successful composition — use sparingly for stateless synchronization.
+
+`produceState` bridges imperative state sources into Compose state; prefer ViewModel's `StateFlow` in MVI.
+
+### Effect Ordering
+
+Effects execute in declaration order after composition. `SideEffect` runs after every composition, `DisposableEffect` setup runs after composition, `LaunchedEffect` coroutines are scheduled asynchronously.
+
+### collectAsStateWithLifecycle
+
+Use `collectAsStateWithLifecycle()` instead of `collectAsState()` to collect only when the composable is in STARTED state:
+
+```kotlin
+val state by viewModel.state.collectAsStateWithLifecycle()
+```
+
+This prevents collection during background states and avoids unnecessary work. `collectAsStateWithLifecycle` is available in both Android and Compose Multiplatform via `androidx.lifecycle:lifecycle-runtime-compose`. Verify your project's lifecycle version supports your KMP targets before using it in `commonMain`.
+
+### CollectEffect — Lifecycle-Aware Effect Collection
+
+```kotlin
+@Composable
+fun <E> CollectEffect(effect: Flow<E>, onEffect: (E) -> Unit) {
+    val lifecycleOwner = LocalLifecycleOwner.current
+    LaunchedEffect(effect, lifecycleOwner) {
+        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+            effect.collect { onEffect(it) }
+        }
+    }
+}
+```
+
+Collect one-off effects at the route level when STARTED; usage patterns live in [mvi.md](mvi.md).
+
+## Modifier Ordering
+
+Order matters. Modifiers apply left-to-right in the chain:
+
+```kotlin
+// Red background wraps padded content
+Modifier.background(Color.Red).padding(16.dp).size(100.dp)
+
+// Padding is inside the sized box, then background wraps everything
+Modifier.size(100.dp).padding(16.dp).background(Color.Red)
+```
+
+### Always accept Modifier parameter
+
+```kotlin
+// GOOD: composable accepts modifier for caller customization
+@Composable
+fun ResultCard(derived: ProductDerived?, modifier: Modifier = Modifier) {
+    Card(modifier = modifier) { /* ... */ }
+}
+```
+
+## Slot Pattern
+
+Accept `@Composable` lambda parameters for flexible, reusable containers:
+
+```kotlin
+@Composable
+fun SectionCard(
+    modifier: Modifier = Modifier,
+    title: @Composable () -> Unit,
+    content: @Composable () -> Unit,
+) {
+    Card(modifier = modifier) {
+        Column(Modifier.padding(16.dp)) {
+            title()
+            Spacer(Modifier.height(8.dp))
+            content()
+        }
+    }
+}
+
+// Usage
+SectionCard(
+    title = { Text("Breakdown", style = MaterialTheme.typography.titleMedium) },
+    content = { ProductBreakdownContent(derived) },
+)
+```
+
+Slots accept `@Composable` lambdas, not pre-composed values. This ensures composition is deferred and scope-aware.
+
+## Composable Extraction Guidelines
+
+| Signal | Prefer |
+|--------|--------|
+| Reused in multiple places, or a single clear visual/behavioral responsibility | Extract |
+| Easier to test in isolation, or independent recomposition skipping helps | Extract |
+| Single use, trivial wrapper around one `Text`/`Icon`, or more parameters than inline clarity | Don't extract |
+| Tightly coupled logic that reads clearer inline | Don't extract |
+
+## CompositionLocal
+
+Provides implicit parameters without threading through the hierarchy.
+
+### When to use
+
+- Theming (`MaterialTheme`, `Colors`, `Typography`)
+- Platform integration (`LocalDensity`, `LocalLifecycleOwner`; `LocalContext` on Android, `LocalPlatformContext` in CMP)
+- Infrequently changing cross-cutting concerns
+
+### When NOT to use
+
+- Frequently changing values (causes widespread recomposition)
+- Values only 1-2 levels deep (pass directly)
+- Dependencies that should use DI
+
+```kotlin
+// GOOD: theme/density accessed via CompositionLocal
+val density = LocalDensity.current
+
+// BAD: custom CompositionLocal for a value only used in one subtree
+val LocalTitle = staticCompositionLocalOf<String> { "" }
+```
+
+In MVI, avoid custom CompositionLocals for feature state. State flows through the ViewModel → route → screen → leaves via explicit parameters.

+ 119 - 0
.claude/skills/compose-skill/references/coroutines-flow-advanced.md

@@ -0,0 +1,119 @@
+# Coroutines & Flow — Advanced Patterns
+
+Backpressure strategies, bridging callback APIs to Flow, concurrency primitives, and testing with Turbine. For core coroutine and Flow patterns (StateFlow/SharedFlow/Channel, operators, dispatchers, scopes, exception handling, stateIn/shareIn), see [coroutines-flow.md](coroutines-flow.md).
+
+## Backpressure
+
+When producer emits faster than consumer processes:
+
+| Strategy | Behavior | Use when |
+|---|---|---|
+| Default (no buffer) | Producer suspends until consumer processes | Simple sequential work |
+| `buffer(capacity)` | Queue between producer and consumer | Smooth speed spikes, process every item |
+| `conflate()` | Drop old values, keep only latest | UI updates, progress bars — stale data unnecessary |
+| `collectLatest { }` | Cancel previous processing when new value arrives | Search — only final result matters |
+
+```kotlin
+// Search with collectLatest: only the last query completes
+queryFlow
+    .debounce(300)
+    .distinctUntilChanged()
+    .collectLatest { query ->
+        val results = repository.search(query) // cancelled if new query arrives
+        _state.update { it.copy(results = results) }
+    }
+```
+
+### flowOn
+
+`flowOn` changes the dispatcher for upstream operators and automatically buffers at the context switch:
+
+```kotlin
+repository.observeProducts()        // runs on IO
+    .map { it.toDomain() }           // runs on IO
+    .flowOn(Dispatchers.IO)           // everything above runs on IO
+    .collect { updateUi(it) }         // runs on caller's dispatcher (Main)
+```
+
+## callbackFlow and channelFlow
+
+### callbackFlow — bridge listener APIs to Flow
+
+Use `callbackFlow` to convert callback-based platform APIs into a `Flow`. In CMP, place these wrappers in `expect/actual` declarations or platform source sets.
+
+```kotlin
+// Android example — LocationManager (place in androidMain for CMP)
+fun LocationManager.locationUpdates(): Flow<Location> = callbackFlow {
+    val listener = LocationListener { location ->
+        trySend(location) // non-blocking, thread-safe
+    }
+    requestLocationUpdates(GPS_PROVIDER, 1000L, 0f, listener)
+    awaitClose { removeUpdates(listener) } // mandatory cleanup
+}
+```
+
+**Rules:**
+- Use `trySend()` (non-blocking) not `send()` (suspending) from callbacks
+- `awaitClose { }` is mandatory — omitting it throws `IllegalStateException`
+- The cleanup block in `awaitClose` unregisters the listener
+
+### channelFlow — concurrent production
+
+```kotlin
+fun loadDashboard(): Flow<DashboardSection> = channelFlow {
+    launch { send(DashboardSection.Profile(fetchProfile())) }
+    launch { send(DashboardSection.Stats(fetchStats())) }
+    launch { send(DashboardSection.Feed(fetchFeed())) }
+}
+```
+
+Use `channelFlow` when producing values from multiple concurrent coroutines. Use `callbackFlow` specifically for wrapping external callback APIs.
+
+## Concurrency Primitives
+
+### Mutex — mutual exclusion
+
+```kotlin
+private val mutex = Mutex()
+private var tokenCache: String? = null
+
+suspend fun getToken(): String = mutex.withLock {
+    tokenCache ?: refreshToken().also { tokenCache = it }
+}
+```
+
+Use Mutex for: token refresh synchronization, shared mutable state protection, sequential access to resources.
+
+### Semaphore — limited concurrency
+
+```kotlin
+private val semaphore = Semaphore(permits = 3)
+
+suspend fun downloadFile(url: String): ByteArray = semaphore.withPermit {
+    httpClient.get(url).body()
+}
+```
+
+Use Semaphore for: rate-limiting concurrent network calls, limiting parallel file operations.
+
+### Why not synchronized?
+
+`synchronized` blocks the thread. Coroutines suspend — blocking a thread holding a coroutine defeats the purpose. Use `Mutex.withLock` instead of `synchronized` in coroutine code.
+
+## Testing with Turbine
+
+### Turbine API quick reference
+
+| Function | Purpose |
+|---|---|
+| `flow.test { }` | Start collecting and asserting |
+| `awaitItem()` | Wait for next emission, fail if timeout |
+| `awaitComplete()` | Assert flow completes |
+| `awaitError()` | Assert flow throws |
+| `expectNoEvents()` | Assert no emissions pending |
+| `cancelAndIgnoreRemainingEvents()` | Clean up after assertions |
+| `cancelAndConsumeRemainingEvents()` | Cancel and return remaining events |
+
+`runTest` from `kotlinx-coroutines-test` provides deterministic coroutine execution — delays are skipped automatically. Use `advanceUntilIdle()` to process all pending coroutines.
+
+For full ViewModel event→state→effect testing patterns with Turbine, see [testing.md](testing.md).

+ 188 - 0
.claude/skills/compose-skill/references/coroutines-flow.md

@@ -0,0 +1,188 @@
+# Kotlin Coroutines & Flow
+
+Coroutines and Flow primitives for Compose apps: StateFlow, SharedFlow, Channel, operators, dispatchers, scopes, and exception handling. Works on all CMP targets.
+
+References:
+- [Coroutines best practices (Android)](https://developer.android.com/kotlin/coroutines/coroutines-best-practices)
+- [Exception handling (Kotlin docs)](https://kotlinlang.org/docs/exception-handling.html)
+- [Turbine (GitHub)](https://github.com/cashapp/turbine)
+
+## StateFlow vs SharedFlow vs Channel
+
+| | StateFlow | SharedFlow | Channel |
+|---|---|---|---|
+| Holds current value | Yes (replay=1, conflated) | No (configurable replay) | No |
+| New collector gets | Latest value immediately | Replayed values (if configured) | Nothing (consumed) |
+| Delivery | All collectors | All collectors | One receiver |
+| Duplicate filtering | `distinctUntilChanged` built-in | None | None |
+| Use for | UI state | Broadcasting events | One-off effects |
+
+### MVI mapping
+
+```kotlin
+class ProductViewModel : ViewModel() {
+    private val _state = MutableStateFlow(ProductState())
+    val state: StateFlow<ProductState> = _state.asStateFlow()
+
+    private val _effects = Channel<ProductEffect>(Channel.BUFFERED)
+    val effects: Flow<ProductEffect> = _effects.receiveAsFlow()
+}
+```
+
+### When to use which
+
+- **Screen state** (loading, data, errors, form input) → `StateFlow`
+- **One-off UI effects** (navigate, snackbar, haptic) → `Channel(BUFFERED)` collected via `CollectEffect`
+- **Broadcasting to multiple collectors** (analytics, logging) → `SharedFlow` with appropriate replay
+- **Hot data streams** (search results reacting to query) → cold `Flow` converted via `stateIn`
+
+### Common mistakes
+
+- StateFlow for one-off events → shows twice on config change (new collector gets latest)
+- `SharedFlow(replay=0)` for mandatory effects → lost when UI detached
+- `Channel()` default (RENDEZVOUS) → suspends sender if no receiver; use `Channel.BUFFERED`
+
+## Flow Operators Quick Reference
+
+### Transforming
+
+| Operator | Purpose |
+|---|---|
+| `map { }` | Transform each value |
+| `mapNotNull { }` | Transform and drop nulls |
+| `filter { }` | Keep values matching predicate |
+| `take(n)` / `drop(n)` | Take first n / skip first n |
+
+### Flattening
+
+| Operator | Behavior | Use when |
+|---|---|---|
+| `flatMapLatest { }` | Cancel previous inner flow | Search queries — only latest |
+| `flatMapConcat { }` | Sequential, wait for completion | Order matters |
+| `flatMapMerge { }` | Concurrent inner flows | Parallel, order irrelevant |
+
+### Combining
+
+| Operator | Behavior | Use when |
+|---|---|---|
+| `combine(flowA, flowB) { a, b -> }` | Emit when ANY emits, latest from each | Multiple independent state sources |
+| `zip(flowA, flowB) { a, b -> }` | Paired emissions only | Synchronized pairs |
+| `merge(flowA, flowB)` | Interleave emissions | Unified event stream |
+
+**Gotcha:** `combine` waits until every upstream emits at least once before producing output.
+
+### Timing / Error / Side effects
+
+| Operator | Purpose |
+|---|---|
+| `debounce(300)` | Wait for pause (search input) |
+| `sample(1000)` | Latest at fixed intervals |
+| `distinctUntilChanged()` | Skip consecutive duplicates |
+| `catch { }` | Handle upstream errors, can `emit()` fallback |
+| `retry(3)` / `retryWhen { cause, attempt -> }` | Retry with optional backoff |
+| `onEach { }` / `onStart { }` / `onCompletion { }` | Side effects |
+
+### Terminal operators
+
+| Operator | Purpose |
+|---|---|
+| `collect { }` / `collectLatest { }` | Collect values (suspends) |
+| `first()` / `toList()` | Single value / all values |
+| `launchIn(scope)` | Start collection in scope |
+| `stateIn(scope)` / `shareIn(scope)` | Convert to hot StateFlow/SharedFlow |
+
+## Dispatchers
+
+| Dispatcher | Use for | CMP support |
+|---|---|---|
+| `Dispatchers.Main` | UI state updates, composable callbacks | All targets |
+| `Dispatchers.IO` | Network, database, file I/O | All targets (since 1.7+) |
+| `Dispatchers.Default` | CPU-heavy computation, sorting, parsing | All targets |
+
+**Main-safe rule:** the callee switches dispatchers, not the caller:
+
+```kotlin
+class ProductRepository(
+    private val api: ProductApi,
+    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
+) {
+    suspend fun getProducts(): List<Product> = withContext(ioDispatcher) {
+        api.getProducts().toDomain()
+    }
+}
+// Caller: viewModelScope.launch { repository.getProducts() } — safe from Main
+```
+
+Inject dispatchers as constructor params for testability.
+
+## Structured Concurrency and Scopes
+
+| Scope | Lifecycle | Use for |
+|---|---|---|
+| `viewModelScope` | ViewModel cleared | ViewModel coroutines (CMP `commonMain` since lifecycle 2.8+) |
+| `lifecycleScope` | Lifecycle destroyed | Android Activity/Fragment only |
+| `rememberCoroutineScope()` | Leaves composition | Compose event handlers |
+| `coroutineScope { }` | All children complete | Parallel decomposition (one fails → all cancel) |
+| `supervisorScope { }` | Child failure independent | Independent parallel tasks |
+
+Use `supervisorScope` when tasks are independent (dashboard sections). Use `coroutineScope` when all must succeed together. Never use `GlobalScope` — no lifecycle, memory leak. Never create unbound `CoroutineScope(Job())` without lifecycle management.
+
+## Exception Handling
+
+### launch vs async
+
+`launch`: exception propagates immediately. `async`: exception deferred until `await()`.
+
+```kotlin
+viewModelScope.launch {
+    try {
+        val data = repository.fetchData()
+        _state.update { it.copy(data = data, isLoading = false) }
+    } catch (e: IOException) {
+        _state.update { it.copy(error = "Network error", isLoading = false) }
+    }
+}
+```
+
+### CancellationException — never swallow
+
+```kotlin
+// BAD: catch(e: Exception) catches CancellationException — zombie coroutine
+// GOOD:
+try { suspendingWork() }
+catch (e: CancellationException) { throw e }
+catch (e: Exception) { handleError(e) }
+```
+
+## stateIn and shareIn
+
+Convert cold `Flow` to hot `StateFlow`/`SharedFlow`. Always declare as `val`, never per function call.
+
+```kotlin
+val products: StateFlow<List<Product>> = repository.observeProducts()
+    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+```
+
+| Strategy | Starts | Stops | Use for |
+|---|---|---|---|
+| `WhileSubscribed(5000)` | First collector | 5s after last gone | ViewModel state — stops upstream when UI gone |
+| `Lazily` | First collector | Never (scope cancel) | Expensive-to-restart shared resources |
+| `Eagerly` | Immediately | Never (scope cancel) | Data needed before first collector |
+
+## Anti-Patterns
+
+| Anti-pattern | Why it hurts | Fix |
+|---|---|---|
+| `GlobalScope.launch { }` | No lifecycle, memory leak | `viewModelScope` or structured scope |
+| `runBlocking` on Main | Blocks UI, ANR | `launch` / `async` from coroutine scope |
+| Swallowing `CancellationException` | Zombie coroutines | Always rethrow |
+| Blocking I/O on `Dispatchers.Default` | Starves CPU pool | `Dispatchers.IO` |
+| Non-suspending loop without `ensureActive()` | Ignores cancellation | Check `isActive` / `ensureActive()` |
+| `stateIn` per function call | Leaks hot flows | Declare as `val`, create once |
+| `catch (e: Throwable)` | Catches everything including OOM | `catch (e: Exception)` + rethrow `CancellationException` |
+| Hardcoded `Dispatchers.IO` | Untestable | Inject dispatcher as constructor param |
+| `combine` without initial values | No output until all emit | `onStart { emit(default) }` |
+
+## Advanced Patterns
+
+For backpressure, callbackFlow/channelFlow, Mutex/Semaphore, and Turbine testing, see [coroutines-flow-advanced.md](coroutines-flow-advanced.md).

+ 232 - 0
.claude/skills/compose-skill/references/cross-platform.md

@@ -0,0 +1,232 @@
+# Cross-Platform (KMP) Specifics
+
+## Sharing Strategy
+
+Share these first: reducers, ViewModels, validators, calculators, formatting policies, screen state models, most screen UI.
+
+Keep platform-specific until proven otherwise: permissions, share sheets, clipboard, haptics, file pickers, notifications, deep links, review prompts, platform input traits, OS navigation shell.
+
+## Placement Guide
+
+### What belongs in `commonMain`
+
+Feature state, intents/messages, reducer/ViewModel logic, calculators, validators, eligibility, repository interfaces, use cases that earn their keep, shared composables, presentation mapping, semantic nav effects and error keys.
+
+### What should remain platform-specific
+
+Runtime permissions, share/open sheet, haptics, clipboard, URLs, billing, notifications, biometrics, manifest/delegate deep links, OS widgets/shortcuts.
+
+### Placement Table
+
+| Concern | Default placement | Why |
+|---|---|---|
+| reducer/ViewModel | `commonMain` | pure, testable, reusable |
+| validator/calculator | `commonMain` | pure domain logic |
+| repository contract | `commonMain` | shared dependency boundary |
+| haptics/share/clipboard | interface + platform impl | app capability, easy to fake |
+| locale/number/date formatter | interface or shared library | locale-sensitive behavior |
+| resource identifiers | `commonMain` UI | shared UI uses shared resources |
+| permission prompt flow | platform-specific | OS-specific behavior |
+| safe-area / keyboard handling | route/UI boundary | platform behavior differs |
+| navigation controller binding | platform/UI shell | ViewModel should not know controller type |
+| analytics SDK integration | platform or shared facade | real implementation differs |
+
+### Dependency Verification for commonMain
+
+**Before claiming `commonMain`:** confirm multiplatform artifacts exist. Much of AndroidX is still Android-only; some libs publish KMP (e.g. `lifecycle-viewmodel`, `datastore-preferences`) with version-dependent surfaces. Check Maven for `-jvm`, `-iosarm64`, `-iosX64`, etc.; use context7 `resolve-library-id` + `query-docs` when available. **If unverifiable**, say so—use platform placement or wrapper interfaces.
+
+## Interfaces vs expect/actual
+
+### Default recommendation
+
+Use **interfaces** for app capabilities: haptics, clipboard, share, URL opener, analytics, date/number formatting, file opener.
+
+Use **`expect/actual`** for thin platform facts or one-off helpers when an interface buys little.
+
+### Practical rule
+
+- **Interface** when the capability has lifetime, DI, fakes, or multiple implementations
+- **`expect/actual`** when it is a tiny platform hook with no domain meaning
+
+### Dependency Injection
+
+Heavy/async/hardware services (GPS, biometrics, keystore): `commonMain` interface + Koin (or similar) for platform impls. Reserve `expect/actual` for tiny sync primitives (UUID, dates, clipboard).
+
+## Platform Bridge Patterns
+
+The rules above cover *when* to prefer interfaces vs `expect/actual`; below is *how* to wire each pattern.
+
+### Choosing the Right Bridge
+
+| Need | Pattern | Why |
+|---|---|---|
+| Service with lifecycle, state, or async (player, auth, payments, analytics) | Interface + DI | Testable, fakeable, swappable impls |
+| Stateless platform fact (UUID, platform name, default locale) | `expect/actual` function | No DI overhead for a one-liner |
+| Reuse existing platform type in common signature | `expect class` + `actual typealias` | Rare — prefer interface when possible |
+
+### Pattern 1: Interface + DI (Primary)
+
+Contract in `commonMain`; platform modules supply impls; DI binds them. ViewModel depends only on the interface. Koin setup: [koin.md](koin.md).
+
+```kotlin
+// commonMain
+interface Player { fun play(uri: String); fun pause(); fun release() }
+
+// androidMain
+class AndroidPlayer(private val context: Context) : Player {
+    private val mp = MediaPlayer()
+    override fun play(uri: String) { mp.setDataSource(context, uri.toUri()); mp.start() }
+    override fun pause() = mp.pause()
+    override fun release() = mp.release()
+}
+
+// iosMain
+class IosPlayer : Player {
+    private var av: AVPlayer? = null
+    override fun play(uri: String) { av = AVPlayer(uRL = NSURL(string = uri)); av?.play() }
+    override fun pause() { av?.pause() }
+    override fun release() { av = null }
+}
+
+// androidMain
+val androidPlayerModule = module { single<Player> { AndroidPlayer(get()) } }
+// iosMain
+val iosPlayerModule = module { single<Player> { IosPlayer() } }
+
+class PlayerViewModel(private val player: Player) : ViewModel() {
+    fun onEvent(e: PlayerEvent) {
+        when (e) { is PlayerEvent.Play -> player.play(e.uri); PlayerEvent.Pause -> player.pause() }
+    }
+}
+```
+
+### Pattern 2: expect/actual for Thin Primitives
+
+Stateless one-liners, no DI/interface/fakes:
+
+```kotlin
+// commonMain
+expect fun randomUUID(): String
+
+// androidMain
+actual fun randomUUID(): String = java.util.UUID.randomUUID().toString()
+
+// iosMain
+actual fun randomUUID(): String = platform.Foundation.NSUUID().UUIDString()
+```
+
+### Pattern 3: expect/actual with Typealias
+
+When a platform type already matches the contract:
+
+```kotlin
+// commonMain
+expect class PlatformDate {
+    fun toEpochMillis(): Long
+}
+
+// jvmMain
+actual typealias PlatformDate = java.time.Instant
+
+// nativeMain
+actual class PlatformDate(private val nsDate: NSDate) {
+    actual fun toEpochMillis(): Long = (nsDate.timeIntervalSince1970 * 1000).toLong()
+}
+```
+
+Prefer interface+DI for fakes or when types do not match 1:1.
+
+### Bridge Anti-Patterns
+
+- `expect/actual` for lifecycle/state/async → interface+DI
+- Platform imports in `commonMain` (compiler flags; still catch in review)
+- Fat `expect/actual` → thin bridge, logic in impls
+- Skipping interfaces when tests need fakes
+
+## Lifecycle
+
+`lifecycle-viewmodel` / `lifecycle-runtime-compose` can expose `ViewModel`, `viewModelScope`, `collectAsStateWithLifecycle` in `commonMain`; not all Lifecycle APIs are MP—depends on androidx/KMP.
+
+- Artifact must publish KMP targets (`-jvm`, `-iosarm64`, …) and expose the API on MP (many APIs stay Android-only); match project targets.
+- **Confirm versions** via context7 or AndroidX notes; if not, say so—wrap platform lifecycle behind interfaces if needed.
+- **Typical in `commonMain` (re-verify):** `ViewModel`, `viewModelScope`, `collectAsStateWithLifecycle`, `koinViewModel()`.
+
+## State Restoration
+
+- `rememberSaveable`: small local UI state only
+- Cross-platform drafts: rehydrate from persistence, not assumed OS restoration parity
+- Serialize ViewModel state only when product requires it
+
+## Keyboard, Focus, and Input
+
+- Test text input on real iOS hardware; isolate quirks at the UI/platform edge
+- No keyboard workaround flags in reducer state; shared UI uses inset/safe-area layout
+- Keep selection/composition local per field when needed
+
+## Safe Area and Layout
+
+Insets-aware shared layouts; verify safe areas, keyboard overlap, sheets, nav chrome. Never put “iOS safe-area hack” into feature state.
+
+## Platform Capabilities
+
+Model haptics, clipboard, share as semantic effects; shell executes them.
+
+```kotlin
+enum class HapticType { Confirm, Error, Selection }
+interface Haptics { fun perform(type: HapticType) }
+sealed interface ProductEffect {
+    data class TriggerHaptic(val type: HapticType) : ProductEffect
+    data class ShareQuote(val text: String) : ProductEffect
+}
+interface ShareText { suspend fun share(text: String) }
+```
+
+## Resources
+
+CMP shared resources (strings, images, fonts, qualifiers, localization, Gradle setup). Full API surface: **[Multiplatform Resources](resources.md)**.
+
+```kotlin
+enum class ValidationMessageKey { Required, InvalidNumber, MustBePositive }
+
+@Composable
+fun ValidationMessage(messageKey: ValidationMessageKey?) {
+    val text = when (messageKey) {
+        ValidationMessageKey.Required -> stringResource(Res.string.error_required)
+        ValidationMessageKey.InvalidNumber -> stringResource(Res.string.error_invalid_number)
+        ValidationMessageKey.MustBePositive -> stringResource(Res.string.error_must_be_positive)
+        null -> return
+    }
+    Text(text = text, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
+}
+```
+
+## Code Examples
+
+### GOOD: shared calculator
+
+```kotlin
+class PriceCalculator {
+    fun calculate(d: PriceDraft): PriceDerived {
+        val w = if (d.includeWaste) 1.10 else 1.0
+        val mat = d.area * d.materialRate * w
+        val lab = d.area * d.laborRate
+        val sub = mat + lab
+        val tax = sub * (d.taxPercent / 100.0)
+        return PriceDerived(mat, lab, sub, tax, sub + tax)
+    }
+}
+```
+
+### BAD: platform leakage in state
+
+```kotlin
+@Immutable
+data class ProductState(
+    val input: ProductInput = ProductInput(),
+    val iosKeyboardInsetHack: Int = 0,
+    val androidHapticPattern: String = "",
+    val shareSheetPresented: Boolean = false,
+)
+```
+
+Platform leakage.

+ 197 - 0
.claude/skills/compose-skill/references/datastore.md

@@ -0,0 +1,197 @@
+# DataStore
+
+Key-value and typed preferences via Kotlin coroutines and Flow. For structured/relational data, use [Room](room-database.md).
+
+References:
+- [DataStore documentation](https://developer.android.com/topic/libraries/architecture/datastore)
+- [Set up DataStore for KMP](https://developer.android.com/kotlin/multiplatform/datastore)
+
+## When to Use
+
+| Need | Solution | Why |
+|------|----------|-----|
+| Key-value settings (theme, locale, flags) | Preferences DataStore | No schema, simple key-value, reactive Flow |
+| Typed settings object with multiple fields | Typed DataStore (JSON serializer) | Type-safe, schema evolution via `@Serializable` data class |
+| Structured data with queries, indexes, relations | Room | SQL-backed, compile-time verified, supports Paging |
+| Large binary blobs or files | Filesystem | DataStore is not designed for large payloads |
+
+**Scope rule:** If you need `WHERE`, `JOIN`, or more than ~100 entries, use Room.
+
+## Critical Rules
+
+1. **One instance per file** — never create multiple `DataStore` instances for the same file. Enforce via DI singleton.
+2. **Immutable types only** — `T` in `DataStore<T>` must be immutable. Mutating breaks transactional consistency.
+3. **No mixing SingleProcess / MultiProcess** — if any access point uses `MultiProcessDataStoreFactory`, all must.
+
+## Setup
+
+> **Always search online for the latest stable versions** before adding dependencies.
+
+```kotlin
+// KMP: shared/build.gradle.kts
+commonMain.dependencies {
+    implementation("androidx.datastore:datastore-preferences:<latest>")
+    // For Typed DataStore: also add androidx.datastore:datastore + kotlinx-serialization-json
+}
+```
+
+For Typed DataStore, also add the `kotlin.plugin.serialization` Gradle plugin. See [official setup](https://developer.android.com/topic/libraries/architecture/datastore#setup).
+
+## KMP Instance Creation
+
+Define factory in `commonMain`; platform source sets provide the file path:
+
+```kotlin
+// commonMain
+fun createDataStore(producePath: () -> String): DataStore<Preferences> =
+    PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() })
+
+internal const val PREFS_FILE = "app_settings.preferences_pb"
+
+// androidMain
+fun createDataStore(context: Context): DataStore<Preferences> = createDataStore(
+    producePath = { context.filesDir.resolve(PREFS_FILE).absolutePath }
+)
+
+// iosMain
+fun createDataStore(): DataStore<Preferences> = createDataStore(
+    producePath = {
+        val dir = NSFileManager.defaultManager.URLForDirectory(
+            NSDocumentDirectory, NSUserDomainMask, null, false, null
+        )
+        requireNotNull(dir).path + "/$PREFS_FILE"
+    }
+)
+
+// jvmMain (Desktop) — use app-specific folder, NOT java.io.tmpdir
+fun createDataStore(): DataStore<Preferences> = createDataStore(
+    producePath = {
+        val appDir = File(System.getProperty("user.home"), ".myapp").apply { mkdirs() }
+        File(appDir, PREFS_FILE).absolutePath
+    }
+)
+```
+
+**Android-only shortcut:** `val Context.settingsDataStore by preferencesDataStore(name = "settings")`.
+
+## Preferences DataStore
+
+| Type | Factory |
+|------|---------|
+| `Int` | `intPreferencesKey("name")` |
+| `Long` | `longPreferencesKey("name")` |
+| `Double` | `doublePreferencesKey("name")` |
+| `Float` | `floatPreferencesKey("name")` |
+| `Boolean` | `booleanPreferencesKey("name")` |
+| `String` | `stringPreferencesKey("name")` |
+| `Set<String>` | `stringSetPreferencesKey("name")` |
+
+### Repository pattern (read + write)
+
+```kotlin
+object PrefsKeys {
+    val DARK_MODE = booleanPreferencesKey("dark_mode")
+    val LOCALE = stringPreferencesKey("locale")
+    val ONBOARDING_DONE = booleanPreferencesKey("onboarding_done")
+}
+
+class SettingsRepository(private val dataStore: DataStore<Preferences>) {
+    val settings: Flow<UserSettings> = dataStore.data
+        .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+        .map { prefs -> UserSettings(darkMode = prefs[PrefsKeys.DARK_MODE] ?: false) }
+
+    suspend fun setDarkMode(enabled: Boolean) {
+        dataStore.edit { it[PrefsKeys.DARK_MODE] = enabled }
+    }
+
+    suspend fun clearAll() { dataStore.edit { it.clear() } }
+}
+```
+
+Always handle `IOException` with `.catch` — the file may be unreadable on first launch or after corruption. `edit` is an atomic read-write-modify transaction.
+
+## Typed DataStore (JSON)
+
+For settings with multiple related fields, use `DataStore<T>` with `kotlinx.serialization`:
+
+```kotlin
+@Serializable
+data class AppSettings(
+    val darkMode: Boolean = false,
+    val locale: String = "en",
+    val itemsPerPage: Int = 20,
+)
+
+object AppSettingsSerializer : Serializer<AppSettings> {
+    override val defaultValue = AppSettings()
+    override suspend fun readFrom(input: InputStream): AppSettings =
+        try { Json.decodeFromString(input.readBytes().decodeToString()) }
+        catch (e: SerializationException) { throw CorruptionException("Cannot read settings", e) }
+    override suspend fun writeTo(t: AppSettings, output: OutputStream) =
+        output.write(Json.encodeToString(t).encodeToByteArray())
+}
+
+val settingsDataStore: DataStore<AppSettings> = DataStoreFactory.create(
+    serializer = AppSettingsSerializer,
+    corruptionHandler = ReplaceFileCorruptionHandler { AppSettings() },
+    produceFile = { File(context.filesDir, "app_settings.json") }
+)
+
+// Read: settingsDataStore.data
+// Write: settingsDataStore.updateData { it.copy(locale = "fr") }
+```
+
+## SharedPreferences Migration
+
+```kotlin
+val dataStore: DataStore<Preferences> by preferencesDataStore(
+    name = "settings",
+    produceMigrations = { context ->
+        listOf(SharedPreferencesMigration(context, "legacy_shared_prefs"))
+    }
+)
+```
+
+Migration runs once on first access. Old file deleted after success.
+
+## MVI Integration
+
+Map `Preferences` to domain models at the repository boundary — never pass `Preferences` or raw key lookups into the ViewModel or UI.
+
+For the ViewModel collection pattern (collecting repository `Flow` into state via `viewModelScope`), see [architecture.md](architecture.md) — Reactive Data Collection.
+
+## DI Integration
+
+Always provide `DataStore` as a **singleton** — multiple instances for the same file cause `IllegalStateException`.
+
+```kotlin
+// Koin: single<DataStore<Preferences>> { createDataStore(get()) }
+// Hilt: @Provides @Singleton fun provideDataStore(...): DataStore<Preferences> = ...
+```
+
+For full module patterns, see [koin.md](koin.md) or [hilt.md](hilt.md).
+
+## Testing
+
+```kotlin
+private fun createTestDataStore(testDir: File): DataStore<Preferences> =
+    PreferenceDataStoreFactory.create(
+        scope = TestScope(UnconfinedTestDispatcher()),
+        produceFile = { File(testDir, "test.preferences_pb") }
+    )
+```
+
+Use a temp directory per test and `deleteRecursively()` in teardown. For ViewModel tests, bypass DataStore with a fake repository backed by `MutableStateFlow`. For testing patterns, see [testing.md](testing.md).
+
+## Anti-Patterns
+
+| Anti-pattern | Why it is harmful | Better replacement |
+|---|---|---|
+| Multiple `DataStore` instances for same file | `IllegalStateException`, data corruption | DI singleton (`@Singleton` / `single`) |
+| `runBlocking` on main thread | Blocks UI, ANRs | Collect `data` Flow in `viewModelScope` |
+| Large objects/lists in DataStore | Entire file read/written every operation | Use Room for structured/large data |
+| Missing `.catch` on `dataStore.data` | `IOException` crashes app | `.catch { if (it is IOException) emit(default) }` |
+| No corruption handler | Corrupted file breaks reads permanently | `ReplaceFileCorruptionHandler` with defaults |
+| `java.io.tmpdir` for Desktop | Data lost on reboot | Use app data dir (`~/Library/Application Support/` etc.) |
+| Reading preferences inside composables | Recomposition storms | Read in repository/ViewModel, expose as `StateFlow` |
+| Passing raw `Preferences` to UI | Leaks storage implementation | Map to domain model at repository boundary |

+ 82 - 0
.claude/skills/compose-skill/references/dependency-injection.md

@@ -0,0 +1,82 @@
+# Dependency Injection in Compose Projects
+
+Shared DI guidance for Jetpack Compose and Compose Multiplatform. For framework-specific setup, see [Koin](koin.md) or [Hilt](hilt.md).
+
+References:
+- [Koin](koin.md) — Koin setup, modules, Nav 3 integration, scopes, testing
+- [Hilt](hilt.md) — Hilt setup, modules, scopes, instrumented testing
+
+## When to Use Hilt vs Koin
+
+| Criterion | Hilt | Koin |
+|---|---|---|
+| Platform | Android-only | Multiplatform (Android, iOS, Desktop, Web) |
+| Dependency resolution | Compile-time | Runtime (DSL) or compile-time (Koin Annotations + KSP) |
+| Error detection | Build-time | Runtime — use `verify()` in tests; KSP annotations add compile-time checks |
+| Setup complexity | Higher (Gradle plugins, annotations) | Lower (DSL modules); annotations optional |
+| Compose Multiplatform | Not supported | Full support |
+| Navigation 3 | `hiltViewModel()` in `entry<T>` blocks; multibinding entry providers — see [navigation-3-di.md](navigation-3-di.md) | `navigation<T>` DSL + `koinEntryProvider()` — see [navigation-3-di.md](navigation-3-di.md) |
+| Navigation 2 | `hiltViewModel()` in composable destinations; graph-scoped VMs — see [navigation-2-di.md](navigation-2-di.md) | `koinViewModel()`, `koinNavViewModel()`, `sharedKoinViewModel()` — see [navigation-2-di.md](navigation-2-di.md) |
+
+**Default recommendation:**
+- **Android-only projects**: Hilt is the default recommendation. Koin is also valid if the team prefers it or the project may become multiplatform later.
+- **Compose Multiplatform projects**: Use Koin — Hilt does not support non-Android targets.
+
+For detailed setup, modules, scoping, and testing, see the dedicated references: [koin.md](koin.md) and [hilt.md](hilt.md). This file stays focused on the framework decision — do not duplicate implementation details here.
+
+## Shared DI Concepts
+
+These principles apply regardless of framework choice:
+
+### Constructor injection as the default
+
+Always inject dependencies through the constructor. Field injection (`@Inject lateinit var`) couples the class to the DI framework and makes testing harder.
+
+### Interface-based design
+
+Bind interfaces to implementations — repositories, data sources, and platform services should be defined as interfaces. This enables swapping implementations in tests without mocking the DI framework.
+
+```kotlin
+// Define interface
+interface UserRepository {
+    suspend fun getUser(id: String): User
+}
+
+// Bind implementation via DI
+// Koin: single<UserRepository> { UserRepositoryImpl(get()) }
+// Hilt: @Binds abstract fun bind(impl: UserRepositoryImpl): UserRepository
+```
+
+### Scope lifecycle alignment
+
+| Scope | When to use | Examples |
+|---|---|---|
+| Singleton | Lives for app lifetime | API client, database, analytics |
+| Activity-retained | Survives config changes | User session, auth state |
+| ViewModel-scoped | Tied to a feature screen | Feature-specific calculators, validators |
+| Factory (new each time) | Stateless or short-lived | Formatters, mappers |
+
+Over-scoping wastes memory; under-scoping creates redundant instances. Match the scope to the dependency's actual lifetime.
+
+### Module organization
+
+Organize DI modules by feature, not by type. Each feature module declares its own dependencies:
+
+```text
+feature-product/
+    ProductModule         → repository, calculator, validator, ViewModel
+feature-settings/
+    SettingsModule        → repository, ViewModel
+core/
+    CoreModule            → API client, database, platform bindings
+```
+
+Combine feature modules in the app module. Platform-specific bindings go in platform modules (`androidMain`, `iosMain`).
+
+### Testing principle
+
+Swap real implementations with fakes via DI configuration — don't mock the DI framework itself. Both Koin and Hilt support module replacement in tests:
+- **Koin**: `appModule.verify()` for graph verification, module overrides in tests
+- **Hilt**: `@TestInstallIn` to replace modules, `hilt-android-testing` for instrumented tests
+
+For ViewModel unit testing (framework-agnostic), see [testing.md](testing.md).

+ 298 - 0
.claude/skills/compose-skill/references/gradle-build.md

@@ -0,0 +1,298 @@
+# Gradle & Build Configuration
+
+Gradle best practices for Compose Multiplatform (CMP) and Android-only Jetpack Compose projects, including AGP 9+ changes.
+
+## 1. Project Structure Patterns
+
+### CMP Project (Android + iOS + optional Desktop)
+
+```text
+MyApp/
+├── settings.gradle.kts
+├── build.gradle.kts              # Root: plugins with apply false
+├── gradle.properties
+├── gradle/libs.versions.toml
+├── composeApp/                   # KMP shared library
+│   └── src/{commonMain,androidMain,iosMain,jvmMain}
+├── androidApp/                   # Thin Android shell (required by AGP 9+)
+├── desktopApp/                   # Optional: Desktop JVM entry point
+└── iosApp/                       # Xcode project (NOT a Gradle module)
+```
+
+**Key points:**
+- `composeApp` is a KMP library containing all shared code
+- `androidApp` is a thin shell — AGP 9's `com.android.application` cannot coexist with KMP plugin
+- `iosApp` is a standalone Xcode project, not a Gradle module
+
+### Android-Only Project
+
+```text
+MyApp/
+├── settings.gradle.kts
+├── build.gradle.kts
+├── gradle/libs.versions.toml
+├── app/                          # Main application module
+├── feature-*/                    # Feature modules
+└── core-*/                       # Shared modules (ui, data, domain)
+```
+
+## 2. Version Catalog (`libs.versions.toml`)
+
+Four sections: `[versions]`, `[libraries]`, `[plugins]`, `[bundles]`. Use comment headers to group by domain.
+
+```toml
+[versions]
+# ---- Build ----
+agp = "9.0.1"
+kotlin = "2.3.10"
+ksp = "2.3.10-1.0.30"
+compose-multiplatform = "1.10.1"
+
+# ---- AndroidX ----
+androidx-lifecycle = "2.9.1"
+
+# ---- Networking ----
+ktor = "3.2.0"
+
+[libraries]
+# BOM-managed libs omit version.ref
+compose-bom = { module = "androidx.compose:compose-bom", version = "2026.03.00" }
+compose-material3 = { module = "androidx.compose.material3:material3" }
+
+# Regular libs use version.ref
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
+kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+
+```
+
+**Naming:** kebab-case keys → dot accessors (`koin-core` → `libs.koin.core`). BOM-managed libraries omit `version.ref`. Use `# ---- Section ----` comment headers to visually group entries by domain.
+
+## 3. Bundles (`[bundles]`)
+
+`[bundles]` groups libraries **always added together** into one alias — convenience only; no change to resolution or alignment. Create bundles when two+ libs are added as a set; group by domain and use comment headers like `[versions]`/`[libraries]`.
+
+```kotlin
+implementation(libs.bundles.androidx.base)
+implementation(libs.bundles.androidx.lifecycle)
+```
+
+**CMP projects** rarely need bundles because `commonMain.dependencies` already groups everything in one place.
+
+## 4. `settings.gradle.kts`
+
+```kotlin
+rootProject.name = "MyApp"
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
+pluginManagement {
+    repositories {
+        google { content { includeGroupByRegex("com\\.android.*|com\\.google.*|androidx.*") } }
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google { content { includeGroupByRegex("com\\.android.*|com\\.google.*|androidx.*") } }
+        mavenCentral()
+    }
+}
+
+include(":composeApp", ":androidApp")
+```
+
+## 5. Root `build.gradle.kts`
+
+Declare plugins with `apply false`. No `allprojects {}`/`subprojects {}` — use convention plugins at scale.
+
+```kotlin
+plugins {
+    alias(libs.plugins.android.application) apply false
+    alias(libs.plugins.android.kmp.library) apply false
+    alias(libs.plugins.kotlin.multiplatform) apply false
+    alias(libs.plugins.compose.multiplatform) apply false
+    alias(libs.plugins.compose.compiler) apply false
+    alias(libs.plugins.ksp) apply false
+}
+```
+
+## 6. AGP 9+ Changes
+
+### Built-in Kotlin
+
+AGP 9 includes Kotlin. Do NOT apply `org.jetbrains.kotlin.android` in Android app modules.
+
+```kotlin
+// ✅ AGP 9+
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.compose.compiler)
+}
+```
+
+### New KMP Library Plugin
+
+Use `com.android.kotlin.multiplatform.library` for KMP modules targeting Android.
+
+```kotlin
+// ✅ AGP 9+ KMP module
+plugins {
+    alias(libs.plugins.kotlin.multiplatform)
+    alias(libs.plugins.android.kmp.library)
+}
+```
+
+### New `compileSdk` DSL
+
+```kotlin
+// Application modules
+android {
+    compileSdk { version = release(35) }
+}
+
+// KMP library modules (inside kotlin { androidLibrary {} })
+kotlin {
+    androidLibrary {
+        compileSdk = 35  // Integer still works here
+    }
+}
+```
+
+### Kotlin Block Outside Android
+
+On AGP 9+, `kotlin {}` must NOT be nested inside `android {}`.
+
+```kotlin
+// ✅ Correct
+kotlin { jvmToolchain(21) }
+android { /* ... */ }
+
+// ❌ Wrong
+android { kotlin { jvmToolchain(21) } }
+```
+
+## 7. Module Patterns
+
+### CMP Shared Module (`composeApp`)
+
+```kotlin
+plugins {
+    alias(libs.plugins.kotlin.multiplatform)
+    alias(libs.plugins.android.kmp.library)
+    alias(libs.plugins.compose.multiplatform)
+    alias(libs.plugins.compose.compiler)
+    alias(libs.plugins.ksp)
+}
+
+kotlin {
+    androidLibrary {
+        namespace = "com.example.shared"
+        compileSdk = 35
+        minSdk = 26
+    }
+
+    listOf(iosArm64(), iosSimulatorArm64()).forEach {
+        it.binaries.framework {
+            baseName = "ComposeApp"
+            isStatic = true
+        }
+    }
+
+    sourceSets {
+        commonMain.dependencies {
+            implementation(compose.runtime)
+            implementation(compose.material3)
+            // Add other common dependencies
+        }
+    }
+}
+
+dependencies {
+    listOf("kspAndroid", "kspIosArm64", "kspIosSimulatorArm64").forEach {
+        add(it, libs.room.compiler)
+    }
+}
+```
+
+Android app module: thin shell with `com.android.application` + `compose-compiler` plugins, depending on `projects.composeApp`.
+
+Desktop module: KMP plugin + `compose.desktop.currentOs`, entry point via `compose.desktop { application { mainClass = "..." } }`.
+
+## 8. `gradle.properties`
+
+```properties
+# Performance
+org.gradle.configuration-cache=true
+org.gradle.caching=true
+org.gradle.parallel=true
+org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC
+
+# Kotlin
+kotlin.code.style=official
+
+# Android
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+
+# CMP (if targeting iOS)
+kotlin.mpp.enableCInteropCommonization=true
+```
+
+## 9. KSP Wiring
+
+```kotlin
+dependencies {
+    listOf("kspAndroid", "kspIosArm64", "kspIosSimulatorArm64").forEach {
+        add(it, libs.room.compiler)
+        add(it, libs.koin.ksp.compiler)
+    }
+}
+
+ksp {
+    arg("KOIN_USE_COMPOSE_VIEWMODEL", "true")
+}
+
+tasks.withType<KotlinCompile>().configureEach {
+    dependsOn(tasks.withType<KspTask>())
+}
+```
+
+## 10. Composite Builds
+
+Conditional `includeBuild` for local library dev (use `if (path.exists())` so CI works without checkout):
+
+```kotlin
+// settings.gradle.kts
+val localLibPath = file("../my-library")
+if (localLibPath.exists()) {
+    includeBuild(localLibPath) {
+        dependencySubstitution {
+            substitute(module("com.example:my-library")).using(project(":my-library"))
+        }
+    }
+}
+```
+
+## 11. Convention Plugins
+
+Introduce convention plugins when 3+ modules duplicate config. Use `build-logic/` included build pattern. Not needed for small projects (≤3 modules).
+
+## 12. Do / Don't
+
+| Do | Don't |
+|----|-------|
+| Version catalog for all dependencies | Hardcode versions in build files |
+| Enable configuration cache, build cache | Use `buildSrc` for versions |
+| `TYPESAFE_PROJECT_ACCESSORS` | `allprojects {}`/`subprojects {}` blocks |
+| Separate `androidApp` from KMP shared (AGP 9+) | Apply `kotlin-android` on AGP 9+ |
+| `apply false` at root | Nest `kotlin {}` inside `android {}` |
+| Conditional `includeBuild` for local dev | Unconditional `includeBuild` (breaks CI) |
+| Convention plugins for 3+ modules | Over-engineer small projects |

+ 278 - 0
.claude/skills/compose-skill/references/hilt.md

@@ -0,0 +1,278 @@
+# Dependency Injection with Hilt (Android-only)
+
+Compile-time DI for Android-only Compose projects with ViewModel and lifecycle integration.
+
+For Hilt vs Koin decision guidance and shared DI concepts, see [dependency-injection.md](dependency-injection.md). For Koin (multiplatform), see [koin.md](koin.md).
+
+References:
+- [Hilt Android docs](https://developer.android.com/training/dependency-injection/hilt-android)
+- [Hilt with Compose](https://developer.android.com/develop/ui/compose/libraries#hilt)
+- [Hilt ViewModel](https://developer.android.com/training/dependency-injection/hilt-jetpack#viewmodels)
+
+## Setup
+
+### Gradle configuration
+
+```kotlin
+// project-level build.gradle.kts
+plugins {
+    alias(libs.plugins.hilt) apply false
+}
+
+// app-level build.gradle.kts
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.kotlin.android)
+    alias(libs.plugins.hilt)
+    alias(libs.plugins.ksp)
+}
+
+dependencies {
+    implementation(libs.hilt.android)
+    ksp(libs.hilt.compiler)
+    
+    // Compose integration
+    implementation(libs.hilt.navigation.compose)
+}
+```
+
+## Application Class
+
+```kotlin
+@HiltAndroidApp
+class MyApplication : Application()
+```
+
+Every Hilt app requires an `@HiltAndroidApp`-annotated Application class.
+
+## Modules
+
+### @Provides — when you need to construct the instance yourself
+
+Use for third-party classes, builder patterns, or anything where you control creation logic:
+
+```kotlin
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+    @Provides
+    @Singleton
+    fun provideApiClient(): ApiClient = ApiClient()
+    
+    @Provides
+    @Singleton
+    fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
+        Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()
+}
+```
+
+### @Binds — when mapping an interface to its implementation
+
+Use for interface-to-implementation bindings. More efficient than `@Provides` (no method body needed, generates less code):
+
+```kotlin
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RepositoryModule {
+    @Binds
+    @Singleton
+    abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
+    
+    @Binds
+    @Singleton
+    abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository
+}
+```
+
+### Feature-scoped modules — @InstallIn(ViewModelComponent)
+
+Use `ViewModelComponent` when dependencies are only needed within a ViewModel and should be cleaned up when the ViewModel is cleared. Use `SingletonComponent` for app-wide shared instances (API clients, databases).
+
+```kotlin
+@Module
+@InstallIn(ViewModelComponent::class)
+object ProductModule {
+    @Provides
+    @ViewModelScoped
+    fun provideProductCalculator(): ProductCalculator = ProductCalculator()
+    
+    @Provides
+    @ViewModelScoped
+    fun provideProductValidator(): ProductValidator = ProductValidator()
+}
+```
+
+## ViewModel Injection
+
+### Basic ViewModel
+
+```kotlin
+@HiltViewModel
+class ProductViewModel @Inject constructor(
+    private val calculator: ProductCalculator,
+    private val repository: ProductRepository,
+) : ViewModel() {
+    // StateFlow<State>, Channel<Effect>, onEvent() — see architecture.md
+}
+```
+
+### ViewModel with SavedStateHandle — when params come from navigation routes
+
+Hilt auto-injects `SavedStateHandle` populated with navigation arguments. Use when the ViewModel receives serializable route params:
+
+```kotlin
+@HiltViewModel
+class DetailViewModel @Inject constructor(
+    private val repository: ItemRepository,
+    savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+    private val itemId: String = checkNotNull(savedStateHandle["itemId"])
+    
+    init {
+        loadItem(itemId)
+    }
+}
+```
+
+### ViewModel with @AssistedInject — when params come from the caller, not navigation
+
+Use when the ViewModel needs values that aren't in navigation arguments (e.g., a complex object, a callback, or a value computed in the composable):
+
+```kotlin
+@HiltViewModel(assistedFactory = DetailViewModel.Factory::class)
+class DetailViewModel @AssistedInject constructor(
+    private val repository: ItemRepository,
+    @Assisted private val itemId: String,
+) : ViewModel() {
+    
+    @AssistedFactory
+    interface Factory {
+        fun create(itemId: String): DetailViewModel
+    }
+}
+
+// Caller passes the value explicitly
+@Composable
+fun DetailRoute(itemId: String) {
+    val viewModel = hiltViewModel<DetailViewModel, DetailViewModel.Factory> { factory ->
+        factory.create(itemId)
+    }
+}
+```
+
+Prefer `SavedStateHandle` for navigation arguments (simpler, survives process death). Use `@AssistedInject` only when `SavedStateHandle` can't carry the data.
+
+## Compose Integration
+
+```kotlin
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() { /* setContent { ... } */ }
+
+@Composable
+fun ProductRoute(viewModel: ProductViewModel = hiltViewModel()) {
+    val state by viewModel.state.collectAsStateWithLifecycle()
+    ProductScreen(state = state, onEvent = viewModel::onEvent)
+}
+```
+
+Every Activity hosting Hilt-injected composables requires `@AndroidEntryPoint`. Use the standard MVI Route/Screen pattern: collect state via `collectAsStateWithLifecycle()`, collect effects via `CollectEffect`, pass `onEvent` to Screen.
+
+## Navigation Integration
+
+**For Nav 3 + Hilt patterns** (entry-scoped ViewModels, multibinding entry providers), see [navigation-3-di.md](navigation-3-di.md) — that is the preferred approach for new projects. For Nav 2 + Hilt patterns (graph-scoped VMs, `@AssistedInject`), see [navigation-2-di.md](navigation-2-di.md).
+
+The patterns below apply to **Navigation Compose (Nav 2)** projects that use Hilt. They remain valid for existing codebases but should not be the starting point for new work.
+
+### Nav 2: hiltViewModel() in composable destinations
+
+Use `hiltViewModel()` as a default parameter in any `composable()` destination — each destination gets its own ViewModel instance scoped to the `NavBackStackEntry`.
+
+### Nav 2: Navigation-scoped ViewModel — when multiple destinations share state
+
+Use when destinations within the same Nav 2 navigation graph need a shared ViewModel (e.g., a multi-step checkout flow where Cart, Shipping, and Payment screens share `CheckoutViewModel`):
+
+```kotlin
+val parentEntry = remember(navController) {
+    navController.getBackStackEntry("checkout_graph")
+}
+val sharedViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
+```
+
+## Scopes
+
+| Scope | Lifecycle | Use case |
+|---|---|---|
+| `@Singleton` | Application | API clients, databases, shared preferences |
+| `@ActivityRetainedScoped` | Activity (survives config change) | User session, auth state |
+| `@ViewModelScoped` | ViewModel | Feature-specific services, calculators |
+| `@ActivityScoped` | Activity instance | Activity-bound resources |
+| `@FragmentScoped` | Fragment instance | Fragment-bound resources (rare in Compose) |
+
+## Hilt in MVI
+
+The only Hilt-specific wiring is `@HiltViewModel` + `@Inject constructor`. The MVI pattern (Event/State/Effect, `onEvent()`) is framework-agnostic — DI only affects constructor injection and injection-site calls.
+
+## Testing
+
+For ViewModel unit tests (no Hilt needed), see [testing.md](testing.md).
+
+### Dependencies
+
+```kotlin
+dependencies {
+    androidTestImplementation(libs.hilt.android.testing)
+    kspAndroidTest(libs.hilt.compiler)
+}
+```
+
+### Hilt instrumented testing
+
+```kotlin
+@HiltAndroidTest
+class CreateItemScreenTest {
+    @get:Rule(order = 0)
+    val hiltRule = HiltAndroidRule(this)
+    
+    @get:Rule(order = 1)
+    val composeRule = createAndroidComposeRule<MainActivity>()
+    
+    @Inject
+    lateinit var repository: ItemRepository
+    
+    @Before
+    fun setup() {
+        hiltRule.inject()
+    }
+    
+    @Test
+    fun saveButton_enabledWhenFieldsFilled() {
+        composeRule.setContent {
+            CreateItemScreen(
+                state = CreateItemState(title = "Test", amount = "100"),
+                onEvent = {},
+            )
+        }
+        
+        composeRule.onNodeWithText("Save").assertIsEnabled()
+    }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+@TestInstallIn(components = [SingletonComponent::class], replaces = [RepositoryModule::class])
+object FakeRepositoryModule {
+    @Provides
+    @Singleton
+    fun provideItemRepository(): ItemRepository = FakeItemRepository()
+}
+```
+
+## Anti-Patterns
+
+| Anti-pattern | Why it is harmful | Better approach |
+|---|---|---|
+| Injecting Context into ViewModel | Lifecycle mismatch, leaks | Use `@ApplicationContext` or move platform code to Repository |
+| Injecting Activity/Fragment into ViewModel | Memory leaks | Pass data via SavedStateHandle or route arguments |
+| `@Inject` on ViewModel without `@HiltViewModel` | ViewModel not managed by Hilt | Always use `@HiltViewModel` with `@Inject constructor` |
+| Manual ViewModel instantiation | Bypasses Hilt injection | Use `hiltViewModel()` in Compose |
+| Installing ViewModel dependencies in `SingletonComponent` | Unnecessary lifecycle extension | Use `ViewModelComponent` or `ViewModelScoped` |

+ 198 - 0
.claude/skills/compose-skill/references/image-loading.md

@@ -0,0 +1,198 @@
+# Image Loading (Coil 3 + Compose Multiplatform)
+
+Production-focused guidance for loading remote and local images in Jetpack Compose and Compose Multiplatform using Coil 3.
+
+References:
+- [Coil Compose docs](https://coil-kt.github.io/coil/compose/)
+- [Coil Getting Started](https://coil-kt.github.io/coil/getting_started/)
+- [Coil Image Loaders](https://coil-kt.github.io/coil/image_loaders/)
+- [Coil Network Images](https://coil-kt.github.io/coil/network/)
+- [Coil Extending the Image Pipeline](https://raw.githubusercontent.com/coil-kt/coil/main/docs/image_pipeline.md)
+- [Coil SVG support](https://coil-kt.github.io/coil/svgs/)
+- [Coil Recipes](https://coil-kt.github.io/coil/recipes/)
+- [Coil 3 upgrade notes](https://coil-kt.github.io/coil/upgrading_to_coil3/)
+
+## Setup and Dependencies
+
+Coil 3 does not include network loading by default. Add `coil-compose` and exactly one network integration.
+
+```kotlin
+// Shared for Compose UI
+implementation("io.coil-kt.coil3:coil-compose:<version>")
+
+// Android/JVM only
+implementation("io.coil-kt.coil3:coil-network-okhttp:<version>")
+
+// Multiplatform-friendly network options
+implementation("io.coil-kt.coil3:coil-network-ktor2:<version>")
+// or
+implementation("io.coil-kt.coil3:coil-network-ktor3:<version>")
+```
+
+If you use Ktor networking, add platform engines for your targets (Android, Apple, JVM).
+
+## Choose the Right API
+
+| Use case | Best API | Why |
+|---|---|---|
+| Most image rendering in UI | `AsyncImage` | Best default; resolves image size from constraints |
+| Need a `Painter` or manual request restart/state observation | `rememberAsyncImagePainter` | More control, lower-level painter API |
+| Need composable slots per loading state and need first-frame state correctness | `SubcomposeAsyncImage` | Slot API with immediate state, but slower |
+
+### Performance note
+
+`SubcomposeAsyncImage` uses subcomposition and is generally less suitable for dense `LazyColumn`/`LazyGrid` cells. Prefer `AsyncImage` for list-heavy screens.
+
+## Default AsyncImage Pattern
+
+Prefer one reusable pattern for avatar/card/list images:
+
+```kotlin
+AsyncImage(
+    model = ImageRequest.Builder(LocalPlatformContext.current)
+        .data(imageUrl)
+        .crossfade(true)
+        .build(),
+    placeholder = painterResource(Res.drawable.placeholder),
+    error = painterResource(Res.drawable.image_error),
+    fallback = painterResource(Res.drawable.image_fallback),
+    contentDescription = title, // null only for decorative images
+    contentScale = ContentScale.Crop,
+    modifier = Modifier.clip(RoundedCornerShape(12.dp)),
+)
+```
+
+For accessibility, provide `contentDescription` unless the image is purely decorative.
+
+## ImageLoader Configuration
+
+Create one shared `ImageLoader` per app process. Multiple loaders fragment memory/disk caches and reduce hit rates.
+
+```kotlin
+setSingletonImageLoaderFactory { context ->
+    ImageLoader.Builder(context)
+        .crossfade(true)
+        .memoryCache {
+            MemoryCache.Builder()
+                .maxSizePercent(context, 0.25)
+                .build()
+        }
+        .diskCache {
+            DiskCache.Builder()
+                .directory(context.cacheDir.resolve("image_cache"))
+                .maxSizePercent(0.02)
+                .build()
+        }
+        .build()
+}
+```
+
+For libraries, prefer `coil-core` and pass your own `ImageLoader` instead of overriding the app singleton.
+
+## Extended Pipeline
+
+Coil's pipeline is extensible and executes in this order:
+
+1. `Interceptor`
+2. `Mapper`
+3. `Keyer`
+4. `Fetcher`
+5. `Decoder`
+
+Register custom components once when building `ImageLoader`:
+
+```kotlin
+val imageLoader = ImageLoader.Builder(context)
+    .components {
+        add(CustomCacheInterceptor())
+        add(ItemMapper())
+        add(ItemKeyer())
+        add(PartialUrlFetcher.Factory())
+        add(SvgDecoder.Factory())
+    }
+    .build()
+```
+
+### Decision table: Need X -> Customize Y
+
+| Need | Customize | Why |
+|---|---|---|
+| Add request retry/short-circuit/global policy | `Interceptor` | Wraps entire pipeline; can modify/proceed/return early. Cross-cutting: timeouts, retries, custom cache layer, metrics. |
+| Accept custom model type in `.data(...)` | `Mapper` | Normalizes domain data to a supported type (for example `ProductImage` → URL string). |
+| Keep custom data memory-cacheable | `Keyer` | Stable memory cache key segment for custom models. If a custom `Fetcher` introduces a new data type, add a matching `Keyer` so memory caching works. |
+| Support custom source/protocol | `Fetcher.Factory<T>` | Data transport: custom scheme, signed URLs, alternate client. |
+| Decode custom encoded data/format | `Decoder.Factory` | Converts fetched source to a renderable image. |
+| Add auth headers for all image requests | Network fetcher + client interceptor | Centralized networking behavior. |
+| Per-request dynamic headers | `ImageRequest.httpHeaders(...)` | Scoped request-level networking metadata. |
+
+### Compose Multiplatform placement
+
+- Domain-level model wrappers and mapping intent in `commonMain`; OkHttp/Android-only client setup in platform source sets; prefer Ktor network for broad CMP.
+- One shared `ImageLoader` configuration per app entry point.
+
+### Pipeline anti-patterns
+
+| Anti-pattern | Problem |
+|---|---|
+| Registering pipeline components per screen/composable; duplicating what request options already cover (`httpHeaders`, cache policy, size resolver) | Fragments caches; redundant complexity |
+| Custom `Fetcher` without a stable `Keyer`; volatile data (timestamps, random values) in cache keys | Poor memory cache hit rate |
+| Heavy blocking work in `Interceptor` without bounds/timeouts; platform-only types in `commonMain` pipeline contracts | Jank; wrong layering for CMP |
+
+For HTTP cache semantics with OkHttp, register `CacheControlCacheStrategy` with the network fetcher when you need response `Cache-Control` behavior.
+
+## Caching Strategy
+
+Default request cache policies are enabled; override `memoryCachePolicy` / `diskCachePolicy` / `networkCachePolicy` only when you need non-default behavior.
+
+### Stable keys for smooth transitions
+
+Use stable keys when the same logical image appears in multiple places (list → detail, shared element).
+
+```kotlin
+ImageRequest.Builder(LocalPlatformContext.current)
+    .data(url)
+    .memoryCacheKey("image-$id")
+    .placeholderMemoryCacheKey("image-$id")
+    .build()
+```
+
+`placeholderMemoryCacheKey` helps avoid visual flashes by reusing an in-memory result as the placeholder for the next request.
+
+## Transformations
+
+Use `.transformations(...)` (for example `RoundedCornersTransformation`) only for pixel-level changes to decoded output. Prefer `Modifier.clip` / shapes for UI-only effects; transformations materialize bitmaps and can collapse animated images to one frame.
+
+## SVG
+
+```kotlin
+implementation("io.coil-kt.coil3:coil-svg:<version>")
+```
+
+Coil auto-detects and decodes SVGs after this dependency is on the classpath. Register `SvgDecoder.Factory()` explicitly only if you need non-default wiring.
+
+## Compose Multiplatform Resources
+
+To load images from Compose Multiplatform resources with Coil, use `Res.getUri(...)`:
+
+```kotlin
+AsyncImage(
+    model = Res.getUri("drawable/sample.jpg"),
+    contentDescription = null,
+)
+```
+
+Use string URIs from `Res.getUri`. Direct compile-safe handles like `Res.drawable.someImage` are not currently passed directly as Coil models.
+
+## List and Shared-Element Patterns
+
+- Prefer `AsyncImage` in list cells.
+- Keep item size predictable to avoid layout thrash.
+- Use stable item keys (`LazyColumn`/`LazyGrid`) and stable cache keys (`memoryCacheKey`) together.
+- For shared-element transitions, reuse memory cache key + placeholder memory cache key between source and destination.
+- If you must use `rememberAsyncImagePainter`, provide a size resolver (`rememberConstraintsSizeResolver`) to avoid always loading original size.
+
+## Preview, Testing, and Debugging
+
+- Compose preview has no network access by default. Use `LocalAsyncImagePreviewHandler` to inject deterministic preview images.
+- Enable `DebugLogger` only in debug builds when diagnosing request/decoder/cache behavior.
+- For testability in large apps, inject a custom/fake `ImageLoader` instead of relying on global singleton state.

+ 208 - 0
.claude/skills/compose-skill/references/ios-swift-interop.md

@@ -0,0 +1,208 @@
+# iOS Swift Interop
+
+## Kotlin → Swift Naming
+
+| Kotlin construct | Swift equivalent |
+|---|---|
+| Top-level function `fun foo()` in `Bar.kt` | `BarKt.foo()` |
+| `object AppInit` | `AppInit.shared` |
+| `companion object` member | Direct on class: `MyClass.value` |
+| `sealed class UiState` | Class hierarchy (or SKIE exhaustive enum) |
+| `suspend fun load()` | SKIE: `async func load()` |
+
+```swift
+// Entry point — top-level function in MainViewController.kt
+let controller = MainViewControllerKt.MainViewController()
+```
+
+## Nullability & Type Bridging
+
+| Kotlin | Swift | Notes |
+|---|---|---|
+| `String` | `String` | Non-null bridged directly |
+| `String?` | `String?` | Optional bridged directly |
+| `Int` / `Long` | `Int32` / `Int64` | Not Swift `Int` — use explicit cast |
+| `Unit` | `KotlinUnit` | Awkward return — avoid in public API |
+
+**Collections:** Kotlin `List<T>` bridges to `[T]` as a read-only copy. Mutability and structural sharing are lost at the boundary. Pass collections across the boundary sparingly — batch, don't iterate.
+
+## Coroutines → Swift Async
+
+| Approach | When to use | Trade-off |
+|---|---|---|
+| **SKIE** | Default for new CMP projects | Automatic `async`/`AsyncSequence`; adds build plugin |
+| **KMP-NativeCoroutines** | Existing projects already using it | Annotation-driven; SKIE preferred for greenfield |
+
+### SKIE (recommended)
+
+SKIE converts `suspend` functions to Swift `async` automatically:
+
+```kotlin
+// commonMain
+suspend fun loadItems(): List<Item> = repository.getAll()
+```
+```swift
+let items = try await viewModel.loadItems() // SKIE-generated async bridge
+```
+
+## Flow → Swift Observation
+
+This is how iOS observes `StateFlow<UiState>` — the critical MVI bridge.
+
+### SKIE: Flow → AsyncSequence
+
+SKIE converts `Flow` to `AsyncSequence`:
+
+```swift
+func observeState() async {
+    for await state in viewModel.state { self.uiState = state }
+}
+```
+
+### Manual StateFlow wrapper
+
+Without SKIE, expose a callback-based observer from Kotlin; Swift holds the returned cancel closure and invokes it in `deinit`.
+
+```kotlin
+// iosMain
+class IosStateCollector<T>(private val flow: StateFlow<T>, private val scope: CoroutineScope) {
+    private var job: Job? = null
+    fun observe(onChange: (T) -> Unit): () -> Unit {
+        job = scope.launch(Dispatchers.Main) { flow.collect { onChange(it) } }
+        return { job?.cancel() }
+    }
+}
+```
+
+## Sealed Classes in Swift
+
+### Without SKIE — non-exhaustive
+
+```swift
+if let loading = state as? UiState.Loading { showSpinner() }
+else if let success = state as? UiState.Success { render(items: success.items) }
+else if let error = state as? UiState.Error { showError(error.message) }
+// No exhaustiveness check — silent bugs when a new sealed subclass is added
+```
+
+### With SKIE — exhaustive Swift enum
+
+```swift
+switch onEnum(of: state) {
+case .loading: showSpinner()
+case .success(let s): render(items: s.items)
+case .error(let e): showError(e.message)
+} // Compiler error if a new sealed subclass is added
+```
+
+### Edge cases
+
+- **Generic sealed classes** — SKIE cannot convert generics to Swift enums; use concrete types at the iOS boundary (e.g., `ItemListState` not `ListState<Item>`)
+- **Nested sealed hierarchies** — SKIE flattens names: `UiState.Error.Network` → `.errorNetwork`
+- **Opt out** — annotate with `@SealedInterop.Disabled` to skip SKIE conversion for a specific class
+
+## iOS API Design Rules
+
+- Keep the public API surface small — use `internal` visibility + `@HiddenFromObjC` to exclude Kotlin internals from the generated ObjC header
+- Avoid generics in public iOS-facing API — ObjC/Swift interop erases or boxes them unpredictably
+- Prefer data classes over deep class hierarchies at the boundary — simpler Swift mapping
+- Set `isStatic = true` in framework configuration for static linkage (smaller binary, faster startup)
+- Minimize Kotlin↔Swift boundary crossings in hot paths — batch data, don't iterate across the boundary
+- Avoid `suspend` functions that return `Unit` — Swift receives `KotlinUnit`, requiring callers to discard it explicitly
+- Expose sealed classes with concrete (non-generic) type parameters for SKIE compatibility
+
+## Compose in SwiftUI App
+
+Use `ComposeUIViewController` to embed a Compose screen inside an existing SwiftUI application. This is the standard path for incremental adoption — add Compose features to a SwiftUI app without rewriting native screens.
+
+### Kotlin entry point
+
+```kotlin
+// iosMain
+fun MainViewController(): UIViewController = ComposeUIViewController { App() }
+```
+
+### Swift bridge
+
+Wrap the `UIViewController` in a `UIViewControllerRepresentable` for SwiftUI:
+
+```swift
+struct ComposeView: UIViewControllerRepresentable {
+    func makeUIViewController(context: Context) -> UIViewController {
+        MainViewControllerKt.MainViewController()
+    }
+    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+}
+```
+
+Use `ComposeView()` anywhere in SwiftUI hierarchy — `NavigationStack`, tab bar, sheet, or as the root view.
+
+### When to use
+
+| Scenario | Approach |
+|---|---|
+| Entire app is Compose | `ComposeUIViewController` as the root in `@main App` |
+| Hybrid app — some screens SwiftUI, some Compose | Embed `ComposeView` per-feature inside SwiftUI navigation |
+| Single Compose widget in a SwiftUI screen | Embed `ComposeView` with a fixed `frame` modifier |
+
+## Native iOS Views in Compose
+
+Use `UIKitView` to embed UIKit or SwiftUI components inside a Compose screen. This is how you use platform-native views (maps, camera, webview) that have no Compose equivalent on iOS.
+
+### `UIKitView` basics
+
+```kotlin
+UIKitView(
+    factory = { MKMapView() },
+    modifier = Modifier.size(300.dp),
+    update = { mapView -> mapView.setRegion(region, animated = true) }
+)
+```
+
+- **`factory`** — creates the `UIView` instance once (like `AndroidView`'s factory)
+- **`update`** — called on recomposition to sync Compose state into the native view
+- **`modifier`** — standard Compose modifier for sizing and layout
+
+### Embedding SwiftUI views
+
+SwiftUI views can't be used directly in `UIKitView`. Wrap them in a `UIHostingController` and pass the controller to a Kotlin factory function:
+
+```kotlin
+// iosMain
+@OptIn(ExperimentalForeignApi::class)
+fun ComposeEntryPointWithNativeView(
+    createViewController: () -> UIViewController
+): UIViewController = ComposeUIViewController {
+    Column(Modifier.fillMaxSize()) {
+        Text("Compose content above")
+        UIKitViewController(
+            factory = createViewController,
+            modifier = Modifier.size(300.dp)
+        )
+    }
+}
+```
+```swift
+MainViewControllerKt.ComposeEntryPointWithNativeView {
+    UIHostingController(rootView: MySwiftUIMapView())
+}
+```
+
+### Decision table
+
+| Need | Use |
+|---|---|
+| UIKit view (`MKMapView`, `WKWebView`, `AVCaptureSession`) | `UIKitView(factory = { ... })` directly in Kotlin |
+| SwiftUI view (`Map`, custom SwiftUI component) | Wrap in `UIHostingController`, pass via `UIKitViewController` |
+| Complex native screen with its own navigation | Keep it in SwiftUI/UIKit, embed Compose screens via `ComposeUIViewController` instead |
+
+## Anti-Patterns
+
+- **Generic `Resource<T>` sealed class exposed to Swift** — SKIE can't convert it; use concrete result types like `ItemListResult`
+- **Observing StateFlow without cancellation cleanup** — memory leak when the view controller is deallocated
+- **Returning `Unit` from public API** — becomes `KotlinUnit` in Swift; use a callback or return a meaningful type
+- **Crossing ObjC boundary in a loop** — each call has marshaling overhead; collect results in Kotlin, return the batch
+- **Exposing mutable Kotlin collections to Swift** — mutations won't reflect; return immutable snapshots
+- **Skipping `@HiddenFromObjC`** — pollutes the Swift API surface with internal helpers
+- **Recreating UIKit views on every recomposition** — `factory` in `UIKitView` runs once; put state-dependent updates in `update`, not `factory`
+- **Skipping `update` in `UIKitView`** — Compose state changes won't propagate to the native view; always implement `update` to sync mutable properties

+ 260 - 0
.claude/skills/compose-skill/references/koin.md

@@ -0,0 +1,260 @@
+# Dependency Injection with Koin
+
+Multiplatform DI for Compose projects with ViewModel, Compose, and Navigation 3 integration.
+
+For Hilt vs Koin decision guidance and shared DI concepts, see [dependency-injection.md](dependency-injection.md). For Hilt (Android-only), see [hilt.md](hilt.md).
+
+References:
+- [Koin for Compose](https://insert-koin.io/docs/reference/koin-compose/compose)
+- [Koin Navigation 3](https://insert-koin.io/docs/reference/koin-compose/navigation3)
+
+## Package Selection
+
+### CMP projects (recommended)
+
+```kotlin
+commonMain.dependencies {
+    implementation(platform("io.insert-koin:koin-bom:$koin_version"))
+    implementation("io.insert-koin:koin-core")
+    implementation("io.insert-koin:koin-compose")
+    implementation("io.insert-koin:koin-compose-viewmodel")
+    implementation("io.insert-koin:koin-compose-viewmodel-navigation")  // Nav 3
+    implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$serialization_version")
+}
+```
+
+### Android-only projects
+
+```kotlin
+dependencies {
+    implementation("io.insert-koin:koin-androidx-compose:$koin_version")  // includes compose + viewmodel
+    implementation("io.insert-koin:koin-compose-viewmodel-navigation:$koin_version")
+}
+```
+
+| Package | Purpose |
+|---|---|
+| `koin-core` | Core DI engine (multiplatform) |
+| `koin-compose` | Base Compose API (`koinInject`) |
+| `koin-compose-viewmodel` | ViewModel injection (`koinViewModel`) |
+| `koin-compose-viewmodel-navigation` | Nav 3 entry provider integration |
+| `koin-androidx-compose` | Android convenience (includes compose + viewmodel) |
+
+Platform support: Android, iOS, Desktop — full. Web — experimental.
+
+## Setup and Starting Koin
+
+Initialize outside Compose with a shared `initKoin` and platform-specific config lambda:
+
+```kotlin
+// commonMain
+fun initKoin(config: KoinAppDeclaration? = null) {
+    startKoin {
+        config?.invoke(this)
+        modules(appModule, featureModules)
+    }
+}
+
+// Android — Application class
+class MyApplication : Application() {
+    override fun onCreate() {
+        super.onCreate()
+        initKoin { androidContext(this@MyApplication); androidLogger() }
+    }
+}
+```
+
+iOS — call from Swift. `do` prefix added because `init` is reserved:
+
+```swift
+import ComposeApp
+@main struct iOSApp: App {
+    init() { InitKoinKt.doInitKoin(config: nil) }
+    var body: some Scene { WindowGroup { ContentView() } }
+}
+```
+
+Alternative — Compose-managed: `KoinApplication(configuration = koinConfiguration { modules(appModule) }) { MainScreen() }`
+
+## Defining Modules
+
+```kotlin
+val appModule = module {
+    // Classic DSL (manual wiring)
+    single<UserRepository> { UserRepositoryImpl() }
+    factory { ProductValidator() }
+    viewModelOf(::ProductViewModel)
+
+    // Compiler Plugin DSL (auto-wiring — requires Koin Compiler Plugin)
+    single<ProductCalculator>()                                   // auto-resolves constructor params
+    single<UserRepositoryImpl>() bind UserRepository::class       // bind exposes impl as interface
+    viewModel<ProductViewModel>()
+}
+```
+
+| DSL | Lifecycle | When to use |
+|---|---|---|
+| `single { }` | App lifetime (singleton) | Stateless services, repositories, API clients, databases |
+| `factory { }` | New instance per call | Stateful/short-lived — validators, formatters, use-cases with request state |
+| `scoped { }` | Bound to a Koin scope | Shared within a flow (e.g., checkout) but not globally |
+| `viewModelOf(::Class)` | ViewModel lifecycle | Survives recomposition + config changes, cleared when owner destroyed |
+
+### Annotations (KSP)
+
+Compile-time safety with multiplatform support. Requires KSP plugin + `koin-annotations`.
+
+```kotlin
+plugins { id("com.google.devtools.ksp") }
+
+kotlin {
+    sourceSets.commonMain.dependencies {
+        implementation("io.insert-koin:koin-annotations:$koin_annotations_version")
+    }
+    sourceSets.named("commonMain").configure {
+        kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
+    }
+}
+
+dependencies {
+    add("kspCommonMainMetadata", "io.insert-koin:koin-ksp-compiler:$koin_annotations_version")
+    add("kspAndroid", "io.insert-koin:koin-ksp-compiler:$koin_annotations_version")
+    // ... add for each target (kspIosArm64, kspIosSimulatorArm64, etc.)
+}
+
+ksp {
+    arg("KOIN_USE_COMPOSE_VIEWMODEL", "true")   // multiplatform ViewModel DSL
+    arg("KOIN_CONFIG_CHECK", "true")            // compile-time verification
+}
+```
+
+| Annotation | Equivalent DSL | Purpose |
+|---|---|---|
+| `@Single` | `single { }` | Singleton |
+| `@Factory` | `factory { }` | New instance each time |
+| `@KoinViewModel` | `viewModelOf(::Class)` | ViewModel declaration |
+| `@InjectedParam` | `parametersOf(...)` | Runtime parameter |
+| `@Module` + `@ComponentScan` | `module { }` | Auto-discover annotated classes in package |
+
+Use generated `.module` property: `modules(AppModule().module)`.
+
+### Feature-first module organization
+
+```kotlin
+val productModule = module {
+    single<ProductRepository> { ProductRepositoryImpl(get()) }
+    viewModelOf(::ProductViewModel)
+}
+val appModule = module { includes(productModule, settingsModule, coreModule) }
+```
+
+### Platform-specific implementations
+
+Use `expect/actual` modules when implementations differ per platform:
+
+```kotlin
+// commonMain
+expect val platformModule: Module
+
+// androidMain
+actual val platformModule = module { single<HapticFeedback> { AndroidHapticFeedback(get()) } }
+
+// iosMain
+actual val platformModule = module { single<HapticFeedback> { IosHapticFeedback() } }
+
+startKoin { modules(appModule, platformModule) }
+```
+
+For platform dependencies (e.g., Android `Context`) in `expect/actual` classes, use `KoinComponent` with `inject()` — justified because constructors must match across platforms. Avoid `KoinComponent` elsewhere.
+
+## Injection in Compose
+
+```kotlin
+// Any dependency
+val service: MyService = koinInject()
+
+// ViewModel — lifecycle-aware
+val viewModel = koinViewModel<HomeViewModel>()
+
+// With runtime parameters
+val viewModel = koinViewModel<DetailViewModel> { parametersOf(itemId) }
+
+// Keyed — unique instance per entity
+val viewModel = koinViewModel<DetailViewModel>(key = "detail_$itemId", parameters = { parametersOf(itemId) })
+```
+
+Inject as default parameters for testability: `fun MyScreen(service: MyService = koinInject())`.
+
+| Function | Platform | When to use |
+|---|---|---|
+| `koinInject<T>()` | All | Non-ViewModel dependencies inside `@Composable` |
+| `koinViewModel<T>()` | All | ViewModel — lifecycle-aware, survives recomposition |
+| `koinActivityViewModel<T>()` | Android | Share ViewModel across all composables in an Activity |
+| `koinEntryProvider<T>()` | All | Wire Nav 3 `NavDisplay` to Koin `navigation<T>` entries |
+| `parametersOf(...)` | All | Pass runtime values to `koinViewModel` or `koinInject` |
+| `get<T>()` | All | Resolve inside `module { }` only — never in composables |
+
+## Navigation 3 Integration
+
+Two approaches for Nav 3 + DI. For full patterns, entry-scoped ViewModels, and modularization, see [navigation-3-di.md](navigation-3-di.md).
+
+```kotlin
+// Koin DSL — entries declared in modules
+val appModule = module {
+    navigation<HomeRoute> { HomeScreen(viewModel = koinViewModel()) }
+    navigation<DetailRoute> { route -> DetailScreen(viewModel = koinViewModel { parametersOf(route.id) }) }
+}
+NavDisplay(backStack = backStack, onBack = { backStack.removeLastOrNull() }, entryProvider = koinEntryProvider())
+```
+
+For Nav 2 patterns, see [navigation-2-di.md](navigation-2-di.md). For migration, see [navigation-migration.md](navigation-migration.md).
+
+## Scopes
+
+```kotlin
+val appModule = module {
+    scope<CheckoutFlow> {
+        scoped { CheckoutState() }
+        viewModel<CheckoutViewModel>()
+    }
+}
+```
+
+`scope<T>` works on all platforms. On Android, `activityRetainedScope { }` survives config changes (same idea, platform-specific).
+
+## Koin in MVI
+
+MVI is framework-agnostic — see [architecture.md](architecture.md). The Koin-specific parts are constructor injection and `koinViewModel()`:
+
+```kotlin
+class ProductViewModel(private val repository: ProductRepository) : ViewModel() {
+    // StateFlow<State>, Channel<Effect>, onEvent() — see architecture.md
+}
+// Module: viewModelOf(::ProductViewModel)
+// Route:  val viewModel = koinViewModel<ProductViewModel>()
+```
+
+## Testing
+
+`verify()` performs a dry-run check — catches missing declarations before runtime:
+
+```kotlin
+class KoinModuleCheck : KoinTest {
+    @Test
+    fun verifyAllModules() {
+        appModule.verify(extraTypes = listOf(SavedStateHandle::class))
+    }
+}
+// commonTest.dependencies { implementation("io.insert-koin:koin-test:$koin_version") }
+```
+
+For ViewModel event→state→effect testing, see [testing.md](testing.md).
+
+## Anti-Patterns
+
+| Anti-pattern | Why it is harmful | Better approach |
+|---|---|---|
+| `factory { MyViewModel() }` for ViewModels | Not lifecycle-aware, new instance on recomposition | `viewModelOf(::MyViewModel)` |
+| Not using `parametersOf` for runtime params | Constructor params unresolved | `koinViewModel { parametersOf(id) }` |
+| `koin-compose` without `koin-compose-viewmodel` | `koinViewModel()` unavailable | Add `koin-compose-viewmodel` |
+| Calling `startKoin` multiple times | `KoinAppAlreadyStartedException` | Call once, use `loadKoinModules` for dynamic additions |
+| Android `Context` in `commonMain` modules | Breaks multiplatform | `expect/actual` platform modules |

+ 161 - 0
.claude/skills/compose-skill/references/lists-grids.md

@@ -0,0 +1,161 @@
+# Lists & Grids
+
+Compose patterns for lazy layouts, applied within MVI architecture.
+
+## LazyColumn and LazyRow
+
+Only compose visible items — use for large or dynamic lists. For small fixed lists (<10 items), prefer `Column`/`Row`.
+
+```kotlin
+LazyColumn(modifier = Modifier.fillMaxSize()) {
+    item { HeaderSection() }
+    items(items = users, key = { it.id }) { user ->
+        UserRow(user = user, onOpen = onOpenUser)
+    }
+    item { FooterSection() }
+}
+```
+
+### DSL patterns
+
+- `item { }` — single composable (header, footer, divider)
+- `items(list, key) { }` — from a list with stable keys
+- `itemsIndexed(list) { index, item -> }` — when index is needed
+
+## Keys
+
+Always provide stable, unique keys when the list can change.
+
+```kotlin
+// GOOD: stable domain ID
+items(users, key = { it.id }) { user -> UserRow(user) }
+
+// BAD: index-based — state corrupts on reorder/remove
+items(users, key = { index }) { user -> UserRow(user) }
+
+// BAD: no key — Compose can't distinguish items reliably
+items(users) { user -> UserRow(user) }
+```
+
+**Rule:** Use domain IDs, not indices. Without stable keys, removing an item corrupts the state of remaining items.
+
+## ContentType for Recycling
+
+Use `contentType` when rendering different item types to enable layout reuse:
+
+```kotlin
+sealed class FeedItem {
+    data class Header(val title: String) : FeedItem()
+    data class Post(val id: String, val content: String) : FeedItem()
+}
+
+LazyColumn {
+    items(
+        items = feedItems,
+        key = { when (it) { is FeedItem.Header -> it.title; is FeedItem.Post -> it.id } },
+        contentType = { when (it) { is FeedItem.Header -> "header"; is FeedItem.Post -> "post" } }
+    ) { item ->
+        when (item) {
+            is FeedItem.Header -> SectionHeader(item.title)
+            is FeedItem.Post -> PostCard(item)
+        }
+    }
+}
+```
+
+Without `contentType`, all items compete for one reuse pool. With it, items reuse layout state efficiently within their type.
+
+## Grids and Pager
+
+### LazyVerticalGrid
+
+```kotlin
+// Fixed columns
+LazyVerticalGrid(columns = GridCells.Fixed(3)) {
+    items(items, key = { it.id }) { item -> GridItem(item) }
+}
+
+// Adaptive columns (responsive) — preferred for responsive layouts
+LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 120.dp)) {
+    items(items, key = { it.id }) { item -> GridItem(item) }
+}
+```
+
+### LazyVerticalStaggeredGrid
+
+For Pinterest-style variable-height layouts:
+
+```kotlin
+LazyVerticalStaggeredGrid(columns = StaggeredGridCells.Fixed(2)) {
+    items(images, key = { it.id }) { image -> ImageCard(image) }
+}
+```
+
+### HorizontalPager / VerticalPager
+
+```kotlin
+val pagerState = rememberPagerState(pageCount = { pages.size })
+
+HorizontalPager(state = pagerState) { page ->
+    PageContent(pages[page])
+}
+
+// Programmatic scroll
+LaunchedEffect(targetPage) { pagerState.animateScrollToPage(targetPage) }
+```
+
+## Scroll State and Derived Logic
+
+```kotlin
+val listState = rememberLazyListState()
+
+// GOOD: derivedStateOf for scroll-dependent UI
+val showScrollToTop by remember {
+    derivedStateOf { listState.firstVisibleItemIndex > 2 }
+}
+
+LazyColumn(state = listState) {
+    items(items, key = { it.id }) { item -> ItemRow(item) }
+}
+
+if (showScrollToTop) {
+    FloatingActionButton(onClick = { scope.launch { listState.animateScrollToItem(0) } }) {
+        Icon(Icons.Default.ArrowUpward, contentDescription = "Scroll to top")
+    }
+}
+```
+
+Keep `LazyListState` local — do not put scroll position in the MVI ViewModel state.
+
+## Nested Scrolling
+
+```kotlin
+// BAD: verticalScroll inside LazyColumn — two scroll containers fight for input
+LazyColumn {
+    item {
+        Column(Modifier.verticalScroll(rememberScrollState())) { /* conflict */ }
+    }
+}
+
+// OK: nested LazyRow inside LazyColumn (different axes)
+LazyColumn {
+    item { LazyRow { items(horizontalItems) { HorizontalCard(it) } } }
+    items(verticalItems) { VerticalRow(it) }
+}
+```
+
+For complex scenarios, use `Modifier.nestedScroll()` with a custom `NestedScrollConnection`.
+
+## List Anti-Patterns
+
+| Anti-pattern | Fix |
+|---|---|
+| No keys on mutable lists | Always provide stable domain ID keys |
+| Index-based keys | Use `it.id`, not position index |
+| Expensive computation inside item lambda | Compute upstream in reducer, pass pre-computed data |
+| Inline `filter`/`sort` inside `items {}` | Sort/filter in reducer or ViewModel before emitting state |
+| `LazyColumn` for 5 fixed items | Use `Column` for small fixed lists |
+| Creating new objects in `key` lambda | Use primitive stable identifiers |
+| Missing `contentType` on multi-type lists | Provide `contentType` for efficient reuse |
+
+For paginated lists with network/database loading, see [Paging 3](paging.md).

+ 246 - 0
.claude/skills/compose-skill/references/material-design.md

@@ -0,0 +1,246 @@
+# Material 3 Theming & Components
+
+## TL;DR Defaults
+
+| Concern | Default |
+|---|---|
+| Theme entry point | `MaterialTheme(colorScheme, typography, shapes)` wrapping app content |
+| Dynamic color | Enable on Android 12+; fall back to brand `ColorScheme` on older APIs |
+| Dark/light | Follow system via `isSystemInDarkTheme()`; expose user override if needed |
+| Color pairing | Always pair `primary`/`onPrimary`, `surface`/`onSurface`, `*Container`/`on*Container` |
+| Typography | Use default M3 type scale; override only specific slots for branding |
+| Shapes | Use default M3 shape scale; override per-slot (`small`, `medium`, `large`) |
+| Scaffold | Use `Scaffold` for screens with app bars, FAB, snackbar, or bottom bar |
+| Navigation | `NavigationSuiteScaffold` auto-switches bar/rail by window size |
+| Snackbar | `SnackbarHostState` in Route; show via `Effect` from ViewModel |
+| Bottom sheet | `ModalBottomSheet` with `SheetState`; control via `show()`/`hide()` |
+| Dialog | `AlertDialog` for simple confirm/dismiss; custom `Dialog` for complex content |
+| Adaptive layout | Derive window size class once at app level; pass down as state |
+
+## Theming Baseline
+
+### Theme Setup
+
+```kotlin
+@Composable
+fun AppTheme(
+    darkTheme: Boolean = isSystemInDarkTheme(),
+    dynamicColor: Boolean = true,
+    content: @Composable () -> Unit
+) {
+    val colorScheme = when {
+        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+            val context = LocalContext.current
+            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+        }
+        darkTheme -> DarkColorScheme
+        else -> LightColorScheme
+    }
+    MaterialTheme(
+        colorScheme = colorScheme,
+        typography = AppTypography,
+        shapes = AppShapes,
+        content = content
+    )
+}
+```
+
+### Key Rules
+
+- Define `LightColorScheme` and `DarkColorScheme` using `lightColorScheme()` / `darkColorScheme()`.
+- Generate brand colors via [Material Theme Builder](https://m3.material.io/theme-builder) for guaranteed tonal palettes.
+- Dynamic color is Android-only; CMP projects fall back to brand schemes on non-Android targets.
+
+## Color Roles and Dark/Light
+
+### Role Pairing Rules
+
+| Container | Content on it |
+|---|---|
+| `primary` | `onPrimary` |
+| `primaryContainer` | `onPrimaryContainer` |
+| `secondary` | `onSecondary` |
+| `secondaryContainer` | `onSecondaryContainer` |
+| `tertiary` | `onTertiary` |
+| `tertiaryContainer` | `onTertiaryContainer` |
+| `surface` | `onSurface` |
+| `surfaceVariant` | `onSurfaceVariant` |
+| `error` | `onError` |
+| `errorContainer` | `onErrorContainer` |
+
+### Accessibility Guardrails
+
+- Always use the correct `on*` color for text/icons on a container.
+- Do not mix unrelated pairs (e.g., `tertiaryContainer` background with `primaryContainer` text).
+- M3 tonal palettes guarantee 3:1+ contrast when paired correctly.
+
+### Do / Don't
+
+| Do | Don't |
+|---|---|
+| `containerColor = primary`, `contentColor = onPrimary` | `containerColor = primary`, `contentColor = tertiaryContainer` |
+| Access colors via `MaterialTheme.colorScheme.*` | Hardcode hex colors in components |
+| Test both light and dark themes | Assume light-only usage |
+
+## Typography and Shapes
+
+### Typography
+
+M3 defines 15 text styles across 5 categories:
+
+| Category | Sizes |
+|---|---|
+| Display | `displayLarge`, `displayMedium`, `displaySmall` |
+| Headline | `headlineLarge`, `headlineMedium`, `headlineSmall` |
+| Title | `titleLarge`, `titleMedium`, `titleSmall` |
+| Body | `bodyLarge`, `bodyMedium`, `bodySmall` |
+| Label | `labelLarge`, `labelMedium`, `labelSmall` |
+
+**Default**: Use M3 defaults. Override individual slots for brand fonts:
+
+```kotlin
+val AppTypography = Typography(
+    titleLarge = TextStyle(fontFamily = BrandFont, fontWeight = FontWeight.SemiBold, fontSize = 22.sp)
+)
+```
+
+### Shapes
+
+M3 shape scale: `extraSmall`, `small`, `medium`, `large`, `extraLarge`.
+
+**Default**: Use M3 defaults. Override only when brand requires specific corner radii:
+
+```kotlin
+val AppShapes = Shapes(
+    medium = RoundedCornerShape(12.dp),
+    large = RoundedCornerShape(16.dp)
+)
+```
+
+## Component Decision Matrix
+
+### Scaffold
+
+| Slot | When to use |
+|---|---|
+| `topBar` | Screen has a top app bar |
+| `bottomBar` | Screen has bottom navigation or bottom app bar |
+| `floatingActionButton` | Primary action needs FAB |
+| `snackbarHost` | Screen can show snackbars |
+| `content` | Main screen content; receives `PaddingValues` to apply |
+
+**Rule**: Always apply `innerPadding` from `Scaffold` to content root.
+
+### Top App Bar
+
+| Variant | Use case | Scroll / default |
+|---|---|---|
+| `TopAppBar` (small) | Simple screens, minimal actions | Default: `pinnedScrollBehavior` unless you need collapse |
+| `CenterAlignedTopAppBar` | Single primary action, centered title | Same bar family as small |
+| `MediumTopAppBar` | Moderate navigation, collapsible on scroll | `exitUntilCollapsedScrollBehavior` (also `enterAlwaysScrollBehavior` where needed) |
+| `LargeTopAppBar` | Hero screens, prominent title, collapsible | Same scroll behavior family as medium |
+
+### Navigation
+
+| Window size | Component |
+|---|---|
+| Compact (phones portrait) | `NavigationBar` (bottom) |
+| Medium/Expanded (tablets, landscape) | `NavigationRail` (side) |
+| Auto-switch | `NavigationSuiteScaffold` |
+
+**Default**: Use `NavigationSuiteScaffold` for apps with 3-5 top-level destinations. It adapts automatically.
+
+```kotlin
+NavigationSuiteScaffold(
+    navigationSuiteItems = {
+        destinations.forEach { dest ->
+            item(
+                selected = currentDest == dest,
+                onClick = { currentDest = dest },
+                icon = { Icon(dest.icon, contentDescription = null) },
+                label = { Text(dest.label) }
+            )
+        }
+    }
+) { DestinationContent(currentDest) }
+```
+
+### Bottom Sheet
+
+| Type | Use case |
+|---|---|
+| `ModalBottomSheet` | Overlays content, dismissible |
+| `BottomSheetScaffold` | Persistent sheet integrated with screen |
+
+**State control**: Use `rememberModalBottomSheetState()` + `SheetState.show()`/`hide()`.
+
+**MVI pattern**: ViewModel emits `Effect.ShowSheet`; Route composable calls `sheetState.show()` in `LaunchedEffect`.
+
+### Snackbar
+
+**Setup**: `SnackbarHostState` remembered in Route; passed to `Scaffold.snackbarHost`.
+
+**Pattern**:
+```kotlin
+val snackbarHostState = remember { SnackbarHostState() }
+LaunchedEffect(Unit) {
+    viewModel.effects.collect { effect ->
+        when (effect) {
+            is Effect.ShowSnackbar -> {
+                val result = snackbarHostState.showSnackbar(effect.message, effect.actionLabel)
+                if (result == SnackbarResult.ActionPerformed) viewModel.onEvent(Event.SnackbarAction)
+            }
+        }
+    }
+}
+Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { /* ... */ }
+```
+
+### Dialog
+
+| Type | Use case |
+|---|---|
+| `AlertDialog` | Simple title + text + confirm/dismiss buttons |
+| `Dialog` + `Card` | Complex content, forms, custom layouts |
+
+**MVI pattern**: Dialog visibility controlled by `state.showDialog: Boolean`. Confirm/dismiss dispatch events.
+
+## Adaptive Layout Defaults
+
+### Window Size Classes
+
+| Class | Width breakpoint | Typical devices |
+|---|---|---|
+| Compact | < 600dp | Phones portrait |
+| Medium | 600dp – 840dp | Tablets portrait, large unfolded |
+| Expanded | ≥ 840dp | Tablets landscape, desktop |
+
+**Rule**: Compute `WindowSizeClass` once at app/activity level via `currentWindowAdaptiveInfo()`. Pass derived layout decisions down as state.
+
+### Canonical Layouts
+
+| Layout | Use case | Compose component |
+|---|---|---|
+| List-detail | Master list + detail pane | `ListDetailPaneScaffold`, `NavigableListDetailPaneScaffold` |
+| Supporting pane | Main content + supplementary info | `SupportingPaneScaffold`, `NavigableSupportingPaneScaffold` |
+| Feed | Grid of browsable content | `LazyVerticalGrid` with `GridCells.Adaptive` |
+
+**Default**: For list-detail apps, use `NavigableListDetailPaneScaffold` which handles pane visibility and back navigation.
+
+**Adaptive navigation:** read `windowSizeClass` (or related adaptive info) once at the root and pass derived flags (e.g. whether to show a top app bar) into your main screen composable.
+
+## M2 to M3 Migration Notes
+
+| M2 | M3 |
+|---|---|
+| `Colors` | `ColorScheme` |
+| `lightColors()` / `darkColors()` | `lightColorScheme()` / `darkColorScheme()` |
+| `BottomNavigation` | `NavigationBar` |
+| `BottomNavigationItem` | `NavigationBarItem` |
+| `ModalBottomSheetLayout` | `ModalBottomSheet` |
+| `ModalDrawer` | `ModalNavigationDrawer` |
+| `Scaffold` with `scaffoldState` | `Scaffold` with `snackbarHost` slot |
+| `BackdropScaffold` | `BottomSheetScaffold` or custom |
+| `TopAppBar` elevation | `TopAppBar` with `scrollBehavior` |
+
+**Key change**: M3 `Scaffold` no longer has `drawerState`. Use `ModalNavigationDrawer` wrapping `Scaffold` instead.

+ 220 - 0
.claude/skills/compose-skill/references/mvi.md

@@ -0,0 +1,220 @@
+# MVI (Event/State/Effect)
+
+MVI pattern: sealed Event contract processed by a single `onEvent()` entry point. Use when the project has chosen MVI.
+
+For shared architecture concepts (state owner selection, domain layer, module rules), see [architecture.md](architecture.md).
+
+## The 3 MVI Types
+
+A non-trivial screen using MVI defines 3 types: `Event`, `State`, `Effect`.
+
+### Event
+
+User actions from UI: button clicks, field changes, lifecycle-start, retry, refresh, back press. Events are the **only** input from the UI into the screen state holder, processed by a single `onEvent()` function.
+
+### State
+
+Immutable data class that fully describes what the screen should render. Given the same state, the screen always looks the same. One state per screen, owned by the screen state holder via `StateFlow<State>`.
+
+State should be **equality-friendly** — use `data class` with immutable collections. Computed properties (`val hasRequiredFields get() = name.isNotBlank()`) are acceptable for trivial derivations. Store canonical values; derive display values at the UI boundary.
+
+### Effect
+
+One-off UI commands that don't belong in state: navigate, show snackbar, trigger haptic, copy/share, open browser.
+
+**Why effects are not state:** if you model "show snackbar" as a boolean in state, you need "consume" logic to flip it back — a classic source of bugs. Effects fire once and are gone.
+
+## Event Naming
+
+Events should be named from the **user's perspective** — what happened, not what should happen.
+
+| Good | Bad |
+|---|---|
+| `OnSaveClick` | `SaveCategory` |
+| `OnTitleChanged` | `UpdateTitle` |
+| `OnRetryClick` | `RetryRequest` |
+| `OnBackClick` | `NavigateBack` |
+
+The event describes a user action; the ViewModel decides how to handle it.
+
+## State Modeling
+
+Use immutable `data class` with computed properties for derivations. For detailed guidance (forms, calculators, avoiding duplicated state), see [architecture.md](architecture.md) — State Modeling for Forms and Calculators.
+
+## Effect Delivery
+
+For Channel vs SharedFlow guidance, see [architecture.md](architecture.md) — Effect Delivery. Default: `Channel<Effect>(Channel.BUFFERED)` with `receiveAsFlow()`.
+
+## Event Processing Flow
+
+```text
+UI gesture / lifecycle signal
+    → Event dispatched via onEvent()
+    → ViewModel processes the event in a when() block
+    → Synchronous events: updateState { copy(...) }
+    → Side effects: sendEffect(effect)
+    → Async work: viewModelScope.launch { ... }
+    → On async completion: updateState { copy(...) } + sendEffect(...)
+```
+
+**Key insight:** `onEvent()` is the single decision point. It decides what happens for each event — update state, send an effect, launch async work, or some combination. This keeps all event→reaction logic in one place.
+
+## Screen State Holder Anatomy
+
+A screen state holder using MVI has three responsibilities:
+
+1. **State ownership** — holds `MutableStateFlow<State>`, exposes `StateFlow<State>`
+2. **Effect delivery** — holds `Channel<Effect>` or the project's equivalent, exposes `Flow<Effect>`
+3. **Event processing** — implements `onEvent()` to handle all events
+
+State is updated via a thread-safe `update` function (e.g., `MutableStateFlow.update { it.copy(...) }` or a wrapper like `updateState { copy(...) }`). Effects are sent via `channel.trySend(effect)`.
+
+## UI Rendering Boundary
+
+### Route composable
+
+Obtains the screen state holder (via `koinViewModel()`, `hiltViewModel()`, manual construction), collects state once via lifecycle-aware collector, collects effects via `CollectEffect` or equivalent, binds navigation/snackbar/sheet/platform APIs.
+
+### Screen composable
+
+Stateless render function receiving state plus `onEvent: (Event) -> Unit` callback.
+
+### Leaf composables
+
+Render sub-state, emit specific callbacks, keep only tiny visual-local state. Do not pass `onEvent` to reusable leaves — adapt to specific callbacks.
+
+### Domain and Data Layer Boundaries
+
+See [architecture.md](architecture.md) — Domain Layer and Where Logic Belongs.
+
+## When MVI Is Appropriate
+
+- Project already uses MVI with a base class or convention
+- Screen has many user actions and you want them enumerated in one sealed type
+- Team values explicit event contracts for debugging, analytics, or time-travel debugging
+- You need exhaustive `when` handling for all UI actions
+- Complex screens with interrelated state transitions
+
+## Code Examples
+
+### BAD: business logic inside composables
+
+```kotlin
+@Composable
+fun LoanCalculatorScreen() {
+    var amountText by rememberSaveable { mutableStateOf("") }
+    var rateText by rememberSaveable { mutableStateOf("") }
+    var yearsText by rememberSaveable { mutableStateOf("") }
+    // Calculation/validation omitted — belongs in state holder, not here.
+    Column {
+        OutlinedTextField(value = amountText, onValueChange = { amountText = it })
+        OutlinedTextField(value = rateText, onValueChange = { rateText = it })
+        OutlinedTextField(value = yearsText, onValueChange = { yearsText = it })
+        Text("Monthly payment: …")
+        Button(onClick = { /* … */ }) { Text("Calculate") }
+    }
+}
+```
+
+Problems: logic and validation live in the composable, hard to test, and recomposition becomes the execution model.
+
+### GOOD: MVI contract — Event, State, Effect
+
+```kotlin
+sealed interface CreateItemEvent {
+    data class OnTitleChanged(val title: String) : CreateItemEvent
+    data class OnAmountChanged(val amount: String) : CreateItemEvent
+    data object OnSaveClick : CreateItemEvent
+    data object OnBackClick : CreateItemEvent
+}
+
+data class CreateItemState(
+    val title: String = "",
+    val amount: String = "",
+    val isSaving: Boolean = false,
+    val errors: Map<String, String> = emptyMap()
+) {
+    val canSave: Boolean get() = title.isNotBlank() && amount.isNotBlank()
+}
+
+sealed interface CreateItemEffect {
+    data object NavigateBack : CreateItemEffect
+    data class ShowMessage(val text: String) : CreateItemEffect
+}
+```
+
+### GOOD: ViewModel with onEvent
+
+Full `save()` (validation + `viewModelScope.launch`): identical body to [mvvm.md](mvvm.md) — **GOOD: ViewModel with named functions**; here it is invoked from `onEvent` instead of public named functions.
+
+```kotlin
+class CreateItemViewModel(
+    private val repository: ItemRepository,
+) : ViewModel() {
+    private val _state = MutableStateFlow(CreateItemState())
+    val state: StateFlow<CreateItemState> = _state.asStateFlow()
+
+    private val _effect = Channel<CreateItemEffect>(Channel.BUFFERED)
+    val effect: Flow<CreateItemEffect> = _effect.receiveAsFlow()
+
+    fun onEvent(event: CreateItemEvent) {
+        when (event) {
+            is CreateItemEvent.OnTitleChanged -> _state.update { it.copy(title = event.title, errors = it.errors - "title") }
+            is CreateItemEvent.OnAmountChanged -> _state.update { it.copy(amount = event.amount, errors = it.errors - "amount") }
+            CreateItemEvent.OnSaveClick -> save()
+            CreateItemEvent.OnBackClick -> _effect.trySend(CreateItemEffect.NavigateBack)
+        }
+    }
+
+    // save(): validate, set isSaving, launch coroutine, update state, trySend ShowMessage / NavigateBack on success or failure
+    private fun save() { /* … */ }
+}
+```
+
+### GOOD: Same pattern with a base class or interface
+
+`class CreateItemViewModel(...) : ViewModel(), MviHost<CreateItemEvent, CreateItemState, CreateItemEffect>` — same `onEvent` / `save()` shape; `updateState` / `sendEffect` from the host. Full base-class pattern: [clean-code.md](clean-code.md), [architecture.md](architecture.md).
+
+### GOOD: Route/Screen/Leaf split
+
+Layering: [architecture.md](architecture.md) — **State Collection and Slicing**. Full Route + `CollectEffect` sample: [mvvm.md](mvvm.md) — Route/Screen/Leaf (swap named callbacks for `onEvent`).
+
+```kotlin
+@Composable
+fun CreateItemRoute(vm: CreateItemViewModel = koinViewModel(), snackbar: SnackbarHostState, onBack: () -> Unit) {
+    val state by vm.state.collectAsStateWithLifecycle()
+    CollectEffect(vm.effect) { e -> when (e) {
+        CreateItemEffect.NavigateBack -> onBack()
+        is CreateItemEffect.ShowMessage -> snackbar.showSnackbar(e.text)
+    }}
+    CreateItemScreen(state, vm::onEvent)
+}
+
+@Composable
+fun CreateItemScreen(state: CreateItemState, onEvent: (CreateItemEvent) -> Unit) {
+    Column {
+        OutlinedTextField(state.title, { onEvent(CreateItemEvent.OnTitleChanged(it)) })
+        OutlinedTextField(state.amount, { onEvent(CreateItemEvent.OnAmountChanged(it)) })
+        Button(onClick = { onEvent(CreateItemEvent.OnSaveClick) }, enabled = !state.isSaving && state.canSave) {
+            Text(if (state.isSaving) "Saving..." else "Save")
+        }
+    }
+}
+```
+
+### GOOD: Event model for form-heavy screens
+
+```kotlin
+enum class FormField { Area, MaterialRate, LaborRate, TaxPercent, Notes }
+
+sealed interface FormEvent {
+    data class FieldChanged(val field: FormField, val raw: String) : FormEvent
+    data class IncludeWasteChanged(val enabled: Boolean) : FormEvent
+    data object SubmitClicked : FormEvent
+    data object RetryClicked : FormEvent
+    data object ScreenShown : FormEvent
+    data object ClearClicked : FormEvent
+}
+```
+
+Pragmatic default for large forms: specific intent names for screen-level actions, generic `FieldChanged(field, raw)` only when many fields are structurally similar.

+ 236 - 0
.claude/skills/compose-skill/references/mvvm.md

@@ -0,0 +1,236 @@
+# MVVM (ViewModel with Named Functions)
+
+MVVM pattern: ViewModel with named public functions instead of sealed events. Use when the project has chosen MVVM.
+
+For shared architecture concepts (state owner selection, domain layer, module rules), see [architecture.md](architecture.md).
+
+## The 2 MVVM Types
+
+A non-trivial screen using MVVM defines 2 types: `State`, `Effect`. User actions call named ViewModel functions directly instead of dispatching sealed events.
+
+### State
+
+Immutable data class that fully describes what the screen should render. Given the same state, the screen always looks the same. One state per screen, owned by the ViewModel via `StateFlow<State>`.
+
+State should be **equality-friendly** — use `data class` with immutable collections. Computed properties (`val hasRequiredFields get() = name.isNotBlank()`) are acceptable for trivial derivations. Store canonical values; derive display values at the UI boundary.
+
+### Effect
+
+One-off UI commands that don't belong in state: navigate, show snackbar, trigger haptic, copy/share, open browser.
+
+**Why effects are not state:** if you model "show snackbar" as a boolean in state, you need "consume" logic to flip it back — a classic source of bugs. Effects fire once and are gone.
+
+## State Modeling
+
+Use immutable `data class` with computed properties for derivations. For detailed guidance (forms, calculators, avoiding duplicated state), see [architecture.md](architecture.md) — State Modeling for Forms and Calculators.
+
+## Effect Delivery
+
+For Channel vs SharedFlow guidance, see [architecture.md](architecture.md) — Effect Delivery. Default: `Channel<Effect>(Channel.BUFFERED)` with `receiveAsFlow()`.
+
+### Effects from Named Functions
+
+Effects are emitted directly from named functions instead of an `onEvent()` dispatcher:
+
+```kotlin
+fun onBackClick() {
+    _effect.trySend(CreateItemEffect.NavigateBack)
+}
+
+fun save() {
+    // ... validation and async work ...
+    _effect.trySend(CreateItemEffect.ShowMessage("Saved"))
+    _effect.trySend(CreateItemEffect.NavigateBack)
+}
+```
+
+## Screen State Holder Anatomy
+
+A MVVM ViewModel has three responsibilities:
+
+1. **State ownership** — holds `MutableStateFlow<State>`, exposes `StateFlow<State>`
+2. **Effect delivery** — holds `Channel<Effect>` or the project's equivalent, exposes `Flow<Effect>`
+3. **Named action functions** — public functions for each user action
+
+State is updated via a thread-safe `update` function (e.g., `MutableStateFlow.update { it.copy(...) }` or a wrapper like `updateState { copy(...) }`). Effects are sent via `channel.trySend(effect)`.
+
+## UI Rendering Boundary
+
+### Route composable
+
+Obtains the ViewModel (via `koinViewModel()`, `hiltViewModel()`, manual construction), collects state once via lifecycle-aware collector, collects effects via `CollectEffect` or equivalent, binds navigation/snackbar/sheet/platform APIs.
+
+The route passes individual callbacks to the screen:
+
+```kotlin
+@Composable
+fun CreateItemRoute(
+    viewModel: CreateItemViewModel = koinViewModel(),
+    snackbarHostState: SnackbarHostState,
+    onNavigateBack: () -> Unit,
+) {
+    val state by viewModel.state.collectAsStateWithLifecycle()
+
+    CollectEffect(viewModel.effect) { effect ->
+        when (effect) {
+            CreateItemEffect.NavigateBack -> onNavigateBack()
+            is CreateItemEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.text)
+        }
+    }
+
+    CreateItemScreen(
+        state = state,
+        onTitleChange = viewModel::onTitleChanged,
+        onAmountChange = viewModel::onAmountChanged,
+        onSaveClick = viewModel::save,
+    )
+}
+```
+
+### Screen composable
+
+Stateless render function receiving state plus individual callbacks:
+
+```kotlin
+@Composable
+fun CreateItemScreen(
+    state: CreateItemState,
+    onTitleChange: (String) -> Unit,
+    onAmountChange: (String) -> Unit,
+    onSaveClick: () -> Unit,
+) {
+    Column {
+        OutlinedTextField(
+            value = state.title,
+            onValueChange = onTitleChange,
+            isError = state.errors.containsKey("title"),
+            label = { Text("Title") },
+        )
+        OutlinedTextField(
+            value = state.amount,
+            onValueChange = onAmountChange,
+            isError = state.errors.containsKey("amount"),
+            label = { Text("Amount") },
+        )
+        Button(
+            onClick = onSaveClick,
+            enabled = !state.isSaving && state.canSave,
+        ) {
+            Text(if (state.isSaving) "Saving..." else "Save")
+        }
+    }
+}
+```
+
+### Leaf composables
+
+Render sub-state, emit specific callbacks, keep only tiny visual-local state. Receive only what they need; do not pass the ViewModel to leaves.
+
+### Domain and Data Layer Boundaries
+
+See [architecture.md](architecture.md) — Domain Layer and Where Logic Belongs.
+
+## When MVVM Is Appropriate
+
+- Project already uses MVVM conventions
+- Screen is straightforward with few user actions
+- Team prefers less boilerplate and direct function calls
+- Migrating from Android View-based MVVM to Compose
+- Named functions provide sufficient discoverability for the screen's complexity
+
+## Code Examples
+
+### GOOD: State and Effect definitions
+
+```kotlin
+data class CreateItemState(
+    val title: String = "",
+    val amount: String = "",
+    val isSaving: Boolean = false,
+    val errors: Map<String, String> = emptyMap()
+) {
+    val canSave: Boolean get() = title.isNotBlank() && amount.isNotBlank()
+}
+
+sealed interface CreateItemEffect {
+    data object NavigateBack : CreateItemEffect
+    data class ShowMessage(val text: String) : CreateItemEffect
+}
+```
+
+### GOOD: ViewModel with named functions
+
+```kotlin
+class CreateItemViewModel(
+    private val repository: ItemRepository,
+) : ViewModel() {
+    private val _state = MutableStateFlow(CreateItemState())
+    val state: StateFlow<CreateItemState> = _state.asStateFlow()
+
+    private val _effect = Channel<CreateItemEffect>(Channel.BUFFERED)
+    val effect: Flow<CreateItemEffect> = _effect.receiveAsFlow()
+
+    fun onTitleChanged(title: String) {
+        _state.update { it.copy(title = title, errors = it.errors - "title") }
+    }
+
+    fun onAmountChanged(amount: String) {
+        _state.update { it.copy(amount = amount, errors = it.errors - "amount") }
+    }
+
+    fun onBackClick() {
+        _effect.trySend(CreateItemEffect.NavigateBack)
+    }
+
+    fun save() {
+        val current = _state.value
+        val errors = /* validate current.title / current.amount */
+        if (errors.isNotEmpty()) {
+            _state.update { it.copy(errors = errors) }
+            return
+        }
+        _state.update { it.copy(isSaving = true, errors = emptyMap()) }
+        viewModelScope.launch {
+            try {
+                repository.create(current.title.trim(), current.amount.toDouble())
+                _state.update { it.copy(isSaving = false) }
+                _effect.trySend(CreateItemEffect.ShowMessage("Saved"))
+                _effect.trySend(CreateItemEffect.NavigateBack)
+            } catch (e: Exception) {
+                _state.update { it.copy(isSaving = false) }
+                _effect.trySend(CreateItemEffect.ShowMessage("Failed: ${e.message}"))
+            }
+        }
+    }
+}
+```
+
+### GOOD: Route/Screen/Leaf split
+
+See the Route example above in **UI Rendering Boundary** for the full Route/Screen split (including `CollectEffect` and callback wiring).
+
+### GOOD: Callback grouping for complex screens
+
+For screens with many actions, group related callbacks into a single interface to reduce parameter count:
+
+```kotlin
+interface CreateItemActions {
+    fun onTitleChanged(title: String)
+    fun onAmountChanged(amount: String)
+    fun onCategorySelected(category: Category)
+    fun onTagsChanged(tags: List<Tag>)
+    fun onSaveClick()
+    fun onDeleteClick()
+    fun onBackClick()
+}
+
+@Composable
+fun CreateItemScreen(
+    state: CreateItemState,
+    actions: CreateItemActions,
+) {
+    // Use actions.onTitleChanged, actions.onSaveClick, etc.
+}
+```
+
+The ViewModel can implement this interface directly. This provides structure without the ceremony of a sealed event class.

+ 139 - 0
.claude/skills/compose-skill/references/navigation-2-di.md

@@ -0,0 +1,139 @@
+# Navigation 2 + Dependency Injection
+
+DI wiring for Nav 2 destinations: destination-scoped and graph-scoped ViewModels with Hilt and Koin.
+
+For Nav 2 core reference (NavHost, tabs, deep links, animations), see [navigation-2.md](navigation-2.md).
+For shared navigation concepts and anti-patterns, see [navigation.md](navigation.md).
+
+## Hilt Integration
+
+### hiltViewModel in composable destinations
+
+Each `composable()` destination gets its own ViewModel instance scoped to the `NavBackStackEntry`:
+
+```kotlin
+composable<Detail> { backStackEntry ->
+    val viewModel = hiltViewModel<DetailViewModel>()
+    DetailScreen(viewModel = viewModel)
+}
+```
+
+### SavedStateHandle for navigation arguments
+
+Hilt auto-injects `SavedStateHandle` populated with navigation arguments. The ViewModel receives route params without manual extraction:
+
+```kotlin
+@HiltViewModel
+class DetailViewModel @Inject constructor(
+    private val repository: ItemRepository,
+    savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+    private val itemId: String = checkNotNull(savedStateHandle["itemId"])
+}
+```
+
+### Graph-scoped shared ViewModel
+
+Share a ViewModel across all destinations within a nested navigation graph (e.g., a multi-step checkout flow):
+
+```kotlin
+composable("checkout/cart") { entry ->
+    val parentEntry = remember(entry) { navController.getBackStackEntry("checkout") }
+    val sharedViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
+    CartScreen(viewModel = sharedViewModel)
+}
+```
+
+All destinations in the `checkout` graph share the same `CheckoutViewModel` instance, which is cleared when the graph is popped from the back stack.
+
+### @AssistedInject for non-navigation params
+
+When a ViewModel needs values that aren't in navigation arguments and can't go through `SavedStateHandle`:
+
+```kotlin
+@HiltViewModel(assistedFactory = EditorViewModel.Factory::class)
+class EditorViewModel @AssistedInject constructor(
+    private val repository: DocRepository,
+    @Assisted private val mode: EditMode,
+) : ViewModel() {
+
+    @AssistedFactory
+    interface Factory {
+        fun create(mode: EditMode): EditorViewModel
+    }
+}
+
+// Composable destination
+composable<Editor> {
+    val viewModel = hiltViewModel<EditorViewModel, EditorViewModel.Factory> { factory ->
+        factory.create(EditMode.CREATE)
+    }
+}
+```
+
+Prefer `SavedStateHandle` for navigation arguments (simpler, survives process death). Use `@AssistedInject` only when `SavedStateHandle` can't carry the data.
+
+## Koin Integration
+
+### koinViewModel in composable destinations
+
+Standard ViewModel injection using `koinViewModel()`:
+
+```kotlin
+composable<Detail> {
+    val detail: Detail = it.toRoute()
+    DetailScreen(viewModel = koinViewModel { parametersOf(detail.itemId) })
+}
+```
+
+### koinNavViewModel — auto-populated SavedStateHandle
+
+`koinNavViewModel()` automatically populates the ViewModel's `SavedStateHandle` with navigation arguments. The ViewModel receives route params via its constructor without manual extraction:
+
+```kotlin
+class DetailViewModel(
+    private val repository: ItemRepository,
+    savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+    private val itemId: String = checkNotNull(savedStateHandle["itemId"])
+}
+
+// Module declaration
+val featureModule = module {
+    viewModelOf(::DetailViewModel)
+}
+
+// Composable destination — SavedStateHandle auto-populated with nav args
+composable("detail/{itemId}") {
+    val viewModel = koinNavViewModel<DetailViewModel>()
+    DetailScreen(viewModel = viewModel)
+}
+```
+
+### sharedKoinViewModel — graph-scoped sharing
+
+Share a ViewModel within a navigation graph. The shared instance lives as long as the graph's back stack entry:
+
+```kotlin
+navigation(startDestination = "checkout/cart", route = "checkout") {
+    composable("checkout/cart") { entry ->
+        val sharedVm = entry.sharedKoinViewModel<CheckoutViewModel>(navController)
+        CartScreen(viewModel = sharedVm)
+    }
+    composable("checkout/shipping") { entry ->
+        val sharedVm = entry.sharedKoinViewModel<CheckoutViewModel>(navController)
+        ShippingScreen(viewModel = sharedVm)
+    }
+}
+```
+
+This is the Koin equivalent of Hilt's `hiltViewModel(navController.getBackStackEntry("checkout"))` pattern.
+
+### Quick reference — Koin Nav 2 injection functions
+
+| Function | Purpose |
+|---|---|
+| `koinViewModel<T>()` | Standard injection — new instance per destination |
+| `koinNavViewModel<T>()` | Like `koinViewModel` but auto-populates `SavedStateHandle` with nav arguments |
+| `sharedKoinViewModel<T>(navController)` | Share ViewModel within a navigation graph (experimental) |
+| `koinViewModel(parameters = { parametersOf(...) })` | Pass runtime values to the ViewModel constructor |

+ 251 - 0
.claude/skills/compose-skill/references/navigation-2.md

@@ -0,0 +1,251 @@
+# Navigation 2
+
+NavHost, NavController, and graph DSL for Jetpack Compose navigation. Nav 2 is **not deprecated** and remains fully supported.
+
+For shared navigation concepts (MVI rules, anti-patterns, version decision guide), see [navigation.md](navigation.md).
+For DI wiring (Hilt/Koin + Nav 2), see [navigation-2-di.md](navigation-2-di.md).
+For migrating to Nav 3, see [navigation-migration.md](navigation-migration.md).
+
+References:
+- [Navigation Compose docs](https://developer.android.com/guide/navigation/get-started)
+- [Type-safe navigation (2.8+)](https://developer.android.com/guide/navigation/design/type-safety)
+- [Navigation with Compose](https://developer.android.com/develop/ui/compose/navigation)
+- [Animate transitions](https://developer.android.com/guide/navigation/use-graph/animate-transitions)
+
+## Core Concepts
+
+Nav 2 has three building blocks:
+
+1. **NavController** — imperative controller that manages the back stack and navigation actions
+2. **NavHost** — composable container that maps routes to composable destinations
+3. **NavGraph** — the navigation graph defined via the `NavHost` DSL
+
+## Basic Setup with String Routes
+
+```kotlin
+@Composable
+fun AppNavigation() {
+    val navController = rememberNavController()
+
+    NavHost(navController = navController, startDestination = "home") {
+        composable("home") {
+            HomeScreen(onNavigateToDetail = { id -> navController.navigate("detail/$id") })
+        }
+        composable("detail/{itemId}") { backStackEntry ->
+            val itemId = backStackEntry.arguments?.getString("itemId") ?: return@composable
+            DetailScreen(itemId = itemId, onBack = { navController.navigateUp() })
+        }
+    }
+}
+```
+
+How you wire ViewModels and state inside each `composable` block depends on your project's architecture — see [navigation.md](navigation.md) for the MVI boundary pattern where navigation is driven by ViewModel effects.
+
+## Type-Safe Routes (2.8+)
+
+From Navigation Compose 2.8+, routes can be `@Serializable` types instead of strings. This is the recommended approach for new Nav 2 code:
+
+```kotlin
+@Serializable data object Home
+@Serializable data class Detail(val itemId: String)
+
+NavHost(navController = navController, startDestination = Home) {
+    composable<Home> {
+        HomeScreen(onNavigateToDetail = { id -> navController.navigate(Detail(id)) })
+    }
+    composable<Detail> { backStackEntry ->
+        val detail: Detail = backStackEntry.toRoute()
+        DetailScreen(itemId = detail.itemId, onBack = { navController.navigateUp() })
+    }
+}
+```
+
+## Navigation Arguments (Legacy String Routes)
+
+For pre-2.8 projects using string routes:
+
+```kotlin
+composable(
+    route = "detail/{itemId}?sort={sort}",
+    arguments = listOf(
+        navArgument("itemId") { type = NavType.StringType },
+        navArgument("sort") { type = NavType.StringType; defaultValue = "name" },
+    )
+) { backStackEntry ->
+    val itemId = backStackEntry.arguments?.getString("itemId") ?: return@composable
+    val sort = backStackEntry.arguments?.getString("sort") ?: "name"
+    DetailScreen(itemId = itemId, sortBy = sort)
+}
+```
+
+Type-safe routes (2.8+) are the recommended default — the `navArgument` DSL is for legacy codebases.
+
+## Common Navigation Actions
+
+```kotlin
+navController.navigate("detail/$id")
+
+navController.navigate("detail/$id") {
+    popUpTo("home") { inclusive = false }
+    launchSingleTop = true
+}
+
+navController.navigateUp()
+
+navController.popBackStack()
+
+// Type-safe (2.8+)
+navController.navigate(Detail(id)) {
+    popUpTo<Home> { inclusive = false }
+    launchSingleTop = true
+}
+```
+
+## Top-Level Tabs with NavigationBar
+
+Use `NavigationBar` with `currentBackStackEntryAsState()`. Track selection with `destination.hierarchy` and `hasRoute(route::class)`.
+
+```kotlin
+@Serializable sealed interface TopLevelRoute {
+    @Serializable data object Home : TopLevelRoute
+    @Serializable data object Search : TopLevelRoute
+    @Serializable data object Profile : TopLevelRoute
+}
+
+@Composable
+fun MainScreen() {
+    val navController = rememberNavController()
+    val navBackStackEntry by navController.currentBackStackEntryAsState()
+    val currentDestination = navBackStackEntry?.destination
+    val tabs = listOf(
+        Triple(TopLevelRoute.Home, Icons.Default.Home, "Home"),
+        Triple(TopLevelRoute.Search, Icons.Default.Search, "Search"),
+        Triple(TopLevelRoute.Profile, Icons.Default.Person, "Profile"),
+    )
+
+    Scaffold(
+        bottomBar = {
+            NavigationBar {
+                tabs.forEach { (route, icon, label) ->
+                    val selected =
+                        currentDestination?.hierarchy?.any { it.hasRoute(route::class) } == true
+                    NavigationBarItem(
+                        selected = selected,
+                        onClick = {
+                            navController.navigate(route) {
+                                popUpTo(navController.graph.findStartDestination().id) {
+                                    saveState = true
+                                }
+                                launchSingleTop = true
+                                restoreState = true
+                            }
+                        },
+                        icon = { Icon(icon, contentDescription = label) },
+                        label = { Text(label) },
+                    )
+                }
+            }
+        },
+    ) { padding ->
+        NavHost(
+            navController = navController,
+            startDestination = TopLevelRoute.Home,
+            modifier = Modifier.padding(padding),
+        ) {
+            composable<TopLevelRoute.Home> { HomeScreen(navController) }
+            composable<TopLevelRoute.Search> { SearchScreen(navController) }
+            composable<TopLevelRoute.Profile> { ProfileScreen(navController) }
+        }
+    }
+}
+```
+
+## Deep Links
+
+Type-safe (2.8+):
+
+```kotlin
+composable<Detail>(
+    deepLinks = listOf(
+        navDeepLink<Detail>(basePath = "https://example.com/detail")
+    )
+) { backStackEntry ->
+    val detail: Detail = backStackEntry.toRoute()
+    DetailScreen(detail.itemId)
+}
+```
+
+## Navigate with Results
+
+Pass data back via `SavedStateHandle` on back stack entries (avoids bloating route arguments):
+
+```kotlin
+// Sender: set on previous entry, then pop
+Button(onClick = {
+    navController.previousBackStackEntry?.savedStateHandle?.set("filter_result", selectedFilter)
+    navController.navigateUp()
+}) { Text("Apply") }
+
+// Receiver: observe on current entry
+val filterResult = navController.currentBackStackEntry
+    ?.savedStateHandle
+    ?.getStateFlow<String?>("filter_result", null)
+    ?.collectAsStateWithLifecycle()
+```
+
+## Nested Navigation Graphs
+
+Group related destinations under a nested graph:
+
+```kotlin
+NavHost(navController = navController, startDestination = "home") {
+    composable("home") { HomeScreen(navController) }
+
+    navigation(startDestination = "checkout/cart", route = "checkout") {
+        composable("checkout/cart") { CartScreen(navController) }
+        composable("checkout/shipping") { ShippingScreen(navController) }
+        composable("checkout/payment") { PaymentScreen(navController) }
+    }
+}
+```
+
+Type-safe: use `navigation<Graph>(startDestination = Route)` with `@Serializable` types — same structure as above.
+
+## Animations
+
+Default transitions on `NavHost`:
+
+```kotlin
+NavHost(
+    navController = navController,
+    startDestination = Home,
+    enterTransition = { slideInHorizontally(initialOffsetX = { it }) + fadeIn() },
+    exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) + fadeOut() },
+    popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) + fadeIn() },
+    popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) + fadeOut() },
+) { /* destinations */ }
+```
+
+## Conditional Navigation (Auth Guards)
+
+Redirect via `startDestination` and clear login from the stack after success:
+
+```kotlin
+@Composable
+fun AppNavigation(isAuthenticated: Boolean) {
+    val navController = rememberNavController()
+    val startDestination = if (isAuthenticated) Home else Login
+
+    NavHost(navController = navController, startDestination = startDestination) {
+        composable<Login> {
+            LoginScreen(onLoginSuccess = {
+                navController.navigate(Home) {
+                    popUpTo<Login> { inclusive = true }
+                }
+            })
+        }
+        composable<Home> { HomeScreen(navController) }
+        composable<Detail> { DetailScreen(navController) }
+    }
+}
+```

+ 174 - 0
.claude/skills/compose-skill/references/navigation-3-di.md

@@ -0,0 +1,174 @@
+# Navigation 3 + Dependency Injection
+
+DI wiring for Nav 3 entries: entry-scoped ViewModels, modularization, and multi-module entry providers with Hilt and Koin.
+
+For Nav 3 core reference (routes, NavDisplay, scenes, animations), see [navigation-3.md](navigation-3.md).
+For shared navigation concepts and anti-patterns, see [navigation.md](navigation.md).
+
+## Entry-Scoped ViewModels
+
+Nav 3 scopes ViewModels to entries via `rememberViewModelStoreNavEntryDecorator()`. Each entry gets its own `ViewModelStoreOwner` — VMs are created when the entry is added to the back stack and cleared when popped.
+
+### BAD: Globally-scoped ViewModel for per-screen data
+
+```kotlin
+val viewModel: DetailViewModel = viewModel() // scoped too broadly, not entry-scoped
+```
+
+### GOOD: Entry-scoped ViewModel
+
+```kotlin
+// Requires rememberViewModelStoreNavEntryDecorator() in entryDecorators
+val viewModel: DetailViewModel = viewModel() // scoped to entry via decorator
+```
+
+For shared state across entries, lift state to a parent composable or use a shared ViewModel at the Activity/App scope.
+
+## Hilt Integration
+
+For general Hilt setup, modules, and scopes, see [hilt.md](hilt.md). Below covers Nav 3–specific patterns only.
+
+### hiltViewModel in entry blocks (Android only)
+
+```kotlin
+entry<Home> {
+    val viewModel = hiltViewModel<HomeViewModel>()
+    HomeScreen(viewModel = viewModel)
+}
+```
+
+### Factory parameters with @AssistedInject
+
+When the ViewModel needs values from the navigation key that aren't in `SavedStateHandle`:
+
+```kotlin
+entry<Create> { createKey ->
+    val viewModel = hiltViewModel<CreationViewModel, CreationViewModel.Factory>(
+        creationCallback = { factory -> factory.create(originalImageUrl = createKey.fileName) },
+    )
+    CreationScreen(viewModel = viewModel)
+}
+```
+
+### Multibinding entry providers for modularization
+
+Each feature module contributes an entry builder via Hilt multibindings. The app module aggregates them automatically:
+
+```kotlin
+// Feature module
+@Module @InstallIn(ActivityRetainedComponent::class)
+object FeatureAModule {
+    @IntoSet @Provides
+    fun provideEntryBuilder(): EntryProviderScope<NavKey>.() -> Unit = {
+        featureAEntryBuilder()
+    }
+}
+
+// App module — MainActivity
+@Inject
+lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<NavKey>.() -> Unit>
+
+NavDisplay(
+    entryProvider = entryProvider {
+        entryBuilders.forEach { builder -> this.builder() }
+    },
+    // ...
+)
+```
+
+## Koin Integration
+
+For general Koin setup, modules, and scopes, see [koin.md](koin.md). Below covers Nav 3–specific patterns only.
+
+### koinViewModel in entry blocks (Android + CMP)
+
+```kotlin
+entry<Details> { key ->
+    val viewModel = koinViewModel<DetailViewModel> { parametersOf(key.id) }
+    DetailScreen(viewModel = viewModel)
+}
+```
+
+### Koin navigation DSL + koinEntryProvider
+
+Declare navigation entries inside Koin modules. Koin aggregates them automatically — no manual entry provider needed:
+
+```kotlin
+val appModule = module {
+    navigation<HomeRoute> { HomeScreen(viewModel = koinViewModel()) }
+    navigation<DetailRoute> { route ->
+        DetailScreen(viewModel = koinViewModel { parametersOf(route.id) })
+    }
+}
+
+NavDisplay(
+    backStack = rememberNavBackStack(HomeRoute),
+    onBack = { backStack.removeLastOrNull() },
+    entryProvider = koinEntryProvider(),
+)
+```
+
+### Platform-specific extensions
+
+| Function | Platform | Description |
+|---|---|---|
+| `koinEntryProvider<T>()` | All (CMP) | Composable entry provider — use in `commonMain` |
+| `getEntryProvider<T>()` | Android | Eager entry provider via `AndroidScopeComponent` |
+
+## Modularization
+
+### api / impl module split
+
+```text
+feature-home/
+  api/
+    HomeNavKey.kt             -- @Serializable data object HomeNavKey : NavKey
+  impl/
+    HomeScreen.kt             -- composable UI
+    HomeEntryBuilder.kt       -- extension function on EntryProviderScope
+```
+
+- **api** — contains only the `NavKey` route definitions. Other features depend on this.
+- **impl** — contains UI, ViewModels, and entry builder. Depends on its own api + other features' api modules.
+
+### Entry builder extension functions
+
+Each feature exposes an extension function; the app module aggregates them:
+
+```kotlin
+// feature-home/impl
+fun EntryProviderScope<NavKey>.homeEntry(navigator: Navigator) {
+    entry<HomeNavKey> {
+        HomeScreen(onItemClick = { navigator.navigate(DetailsNavKey(it)) })
+    }
+}
+
+// app module
+NavDisplay(
+    entryProvider = entryProvider {
+        homeEntry(navigator)
+        searchEntry(navigator)
+        profileEntry(navigator)
+    },
+    // ...
+)
+```
+
+How you wire the ViewModel and state inside each entry depends on your project's architecture. Navigation is driven by ViewModel effects — the route layer translates semantic effects to back-stack operations.
+
+### Koin module aggregation (CMP)
+
+```kotlin
+// Feature module
+val featureModule = module {
+    navigation<HomeNavKey> { HomeScreen(viewModel = koinViewModel()) }
+    navigation<ProfileNavKey> { ProfileScreen(viewModel = koinViewModel()) }
+}
+
+// App module
+NavDisplay(
+    backStack = backStack,
+    onBack = { backStack.removeLastOrNull() },
+    entryProvider = koinEntryProvider(),
+)
+```

+ 229 - 0
.claude/skills/compose-skill/references/navigation-3.md

@@ -0,0 +1,229 @@
+# Navigation 3
+
+Navigation 3 for Compose and CMP: you own the back stack as state, the library renders it. Verify artifact maturity before production use.
+
+For shared navigation concepts (MVI rules, anti-patterns, version decision guide), see [navigation.md](navigation.md).
+For DI wiring (Hilt/Koin + Nav 3), see [navigation-3-di.md](navigation-3-di.md).
+For migrating from Nav 2, see [navigation-migration.md](navigation-migration.md).
+
+References:
+- [Android Nav 3 docs](https://developer.android.com/guide/navigation/navigation-3)
+- [Nav 3 state management](https://developer.android.com/guide/navigation/navigation-3/save-state)
+- [nav3-recipes repo](https://github.com/android/nav3-recipes)
+- [CMP Nav 3 recipes](https://github.com/terrakok/nav3-recipes)
+
+## Core Architecture
+
+Nav 3 has four building blocks:
+
+1. **Keys** — `@Serializable` types identifying destinations
+2. **Back stack** — a `SnapshotStateList` you own and mutate directly
+3. **NavEntry** — wraps a key with composable content and optional metadata
+4. **NavDisplay** — observes back stack, resolves keys via entry provider, picks a Scene, renders
+
+```text
+User interaction
+  -> backStack.add(key) / backStack.removeLastOrNull()
+  -> NavDisplay observes change
+  -> entryProvider resolves key -> NavEntry
+  -> SceneStrategy picks layout
+  -> Scene renders content
+```
+
+| Type | Role |
+|---|---|
+| `NavKey` | Marker interface for serializable destination keys |
+| `NavEntry` | Key + composable content + metadata map |
+| `NavDisplay` | Observes back stack, manages scenes and animations |
+| `Scene` / `SceneStrategy` | Decides layout (single pane, list-detail, dialog) |
+| `NavEntryDecorator` | Cross-cutting concern (ViewModel scoping, saveable state) |
+
+## Route Definition
+
+Define routes as `@Serializable` data classes/objects. Group with sealed interfaces for type safety:
+
+```kotlin
+@Serializable sealed interface AppRoute : NavKey
+@Serializable data object Home : AppRoute
+@Serializable data class Details(val id: String) : AppRoute
+@Serializable data object Settings : AppRoute
+```
+
+For platform-specific types in route arguments, provide a custom `KSerializer`. In CMP, prefer `String` paths or `expect/actual` wrappers.
+
+## Back Stack Creation and Persistence
+
+```kotlin
+// Recommended — persists across config changes and process death (keys must be @Serializable + NavKey)
+val backStack = rememberNavBackStack(Home)
+
+// Simple — no persistence, prototyping only
+val backStack = remember { mutableStateListOf<Any>(Home) }
+```
+
+### CMP: Polymorphic serialization for non-JVM
+
+Non-JVM CMP targets need `SavedStateConfiguration` plus a `SerializersModule` with polymorphic `NavKey` subclasses (e.g. `subclassesOfSealed<AppRoute>()`).
+
+Details: [Nav 3 state management](https://developer.android.com/guide/navigation/navigation-3/save-state).
+
+## NavDisplay Configuration
+
+```kotlin
+NavDisplay(
+    backStack = backStack,
+    onBack = { backStack.removeLastOrNull() },
+    entryDecorators = listOf(
+        rememberSaveableStateHolderNavEntryDecorator(),
+        rememberViewModelStoreNavEntryDecorator(),
+    ),
+    sceneStrategy = listDetailStrategy,
+    transitionSpec = { slideInHorizontally(initialOffsetX = { it }) togetherWith slideOutHorizontally(targetOffsetX = { -it }) },
+    popTransitionSpec = { slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) },
+    entryProvider = entryProvider {
+        entry<Home> {
+            HomeScreen(onNavigateToDetails = { id -> backStack.add(Details(id)) })
+        }
+        entry<Details>(metadata = mapOf("pane" to "detail")) { key ->
+            DetailScreen(id = key.id, onNavigateBack = { backStack.removeLastOrNull() })
+        }
+    },
+)
+```
+
+Each `entry<Key>` receives the typed key. Pass `metadata` to control scene placement and per-entry animations. For ViewModel/state wiring inside entries, see [navigation.md](navigation.md) and [navigation-3-di.md](navigation-3-di.md).
+
+## Top-Level Tabs and Dashboard Navigation
+
+```kotlin
+data class TopLevelNavItem(val selectedIcon: ImageVector, val unselectedIcon: ImageVector, val label: String)
+
+val TOP_LEVEL_ITEMS = mapOf(
+    Home to TopLevelNavItem(Icons.Filled.Home, Icons.Outlined.Home, "Home"),
+    Search to TopLevelNavItem(Icons.Filled.Search, Icons.Outlined.Search, "Search"),
+    Profile to TopLevelNavItem(Icons.Filled.Person, Icons.Outlined.Person, "Profile"),
+)
+
+@Stable
+class NavigationState(val backStack: SnapshotStateList<NavKey>, val topLevelKeys: Set<NavKey>) {
+    val currentKey: NavKey get() = backStack.last()
+    val currentTopLevelKey: NavKey? get() = backStack.lastOrNull { it in topLevelKeys }
+}
+
+class Navigator(private val state: NavigationState) {
+    fun navigate(key: NavKey) {
+        if (key in state.topLevelKeys) {
+            while (state.backStack.size > 1) state.backStack.removeLast()
+            if (state.backStack.lastOrNull() != key) state.backStack[0] = key
+        } else { state.backStack.add(key) }
+    }
+    fun goBack() { state.backStack.removeLastOrNull() }
+}
+```
+
+Use `NavigationSuiteScaffold` (or custom scaffold) with `NavDisplay` inside.
+
+## ViewModel Scoping
+
+Always include both entry decorators:
+
+```kotlin
+entryDecorators = listOf(
+    rememberSaveableStateHolderNavEntryDecorator(),   // preserves rememberSaveable while on stack
+    rememberViewModelStoreNavEntryDecorator(),         // per-entry ViewModelStoreOwner
+)
+```
+
+VMs created when entry added, cleared when popped. For DI-specific injection patterns, see [navigation-3-di.md](navigation-3-di.md).
+
+## Scenes and Adaptive Layouts
+
+### DialogSceneStrategy
+
+```kotlin
+entry<ConfirmDialog>(metadata = DialogSceneStrategy.dialog()) { key ->
+    AlertDialog(onDismissRequest = { backStack.removeLastOrNull() }, /* ... */)
+}
+```
+
+### BottomSheetSceneStrategy
+
+```kotlin
+entry<FilterSheet>(metadata = BottomSheetSceneStrategy.bottomSheet()) { key ->
+    FilterContent(onApply = { backStack.removeLastOrNull() })
+}
+```
+
+### Material 3 Adaptive list-detail
+
+```kotlin
+val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
+
+NavDisplay(
+    sceneStrategy = listDetailStrategy,
+    entryProvider = entryProvider {
+        entry<ConversationList>(metadata = ListDetailSceneStrategy.listPane(
+            detailPlaceholder = { Text("Select a conversation") }
+        )) { ConversationListScreen(onSelect = { backStack.add(ConversationDetail(it)) }) }
+
+        entry<ConversationDetail>(metadata = ListDetailSceneStrategy.detailPane()) { key ->
+            ConversationDetailScreen(key.id)
+        }
+    },
+)
+```
+
+Automatically adapts: side-by-side on wide screens, single pane on narrow.
+
+### Chaining strategies
+
+```kotlin
+val strategy = dialogStrategy then bottomSheetStrategy then listDetailStrategy
+// First match wins. SinglePaneSceneStrategy is always implicit fallback.
+```
+
+## Animations
+
+### Global transitions on NavDisplay
+
+Set `transitionSpec`, `popTransitionSpec`, and `predictivePopTransitionSpec` on `NavDisplay` (see configuration example above).
+
+### Per-entry overrides via metadata
+
+```kotlin
+entry<ModalRoute>(
+    metadata = NavDisplay.transitionSpec {
+        slideInVertically(initialOffsetY = { it }) togetherWith ExitTransition.KeepUntilTransitionsFinished
+    } + NavDisplay.popTransitionSpec {
+        EnterTransition.None togetherWith slideOutVertically(targetOffsetY = { it })
+    }
+) { ModalScreen() }
+```
+
+## Back Stack Manipulation Patterns
+
+```kotlin
+backStack.add(Details("123")) // forward
+backStack.removeLastOrNull() // back
+backStack.removeAll { it is Details }; backStack.add(Details(newId)) // replace duplicate Details
+backStack.clear(); backStack.addAll(listOf(Home, Details(deepLinkId))) // synthetic stack (e.g. deep link)
+while (backStack.size > 1) backStack.removeLast(); backStack[0] = targetKey // tabs: pop to root, swap root key
+```
+
+## Deep Links
+
+Nav 3 does not parse deep links — you own this. Pattern: parse URI → extract args into `NavKey` → build synthetic back stack → set before first composition.
+
+```kotlin
+// Android Activity or CMP entry point
+val backStack = rememberNavBackStack(Home)
+
+LaunchedEffect(deepLinkId) {
+    if (deepLinkId != null) {
+        backStack.clear()
+        backStack.addAll(listOf(Home, Details(deepLinkId)))
+    }
+}
+```
+
+Registration lives in platform entry points: `AndroidManifest.xml` intent filters, App Delegate/SceneDelegate on iOS, URL handlers on Desktop. Back stack construction logic can live in shared `commonMain`.

+ 120 - 0
.claude/skills/compose-skill/references/navigation-migration.md

@@ -0,0 +1,120 @@
+# Migrating from Nav 2 to Nav 3
+
+Nav 2 → Nav 3 migration based on [official docs](https://developer.android.com/guide/navigation/migrate-to-nav3). Nav 2 is **not deprecated** — migration is optional.
+
+For Nav 3 full reference, see [navigation-3.md](navigation-3.md).
+For Nav 2 full reference, see [navigation-2.md](navigation-2.md).
+For shared concepts and decision guide, see [navigation.md](navigation.md).
+
+## Key Conceptual Shifts
+
+| Nav 2 | Nav 3 |
+|---|---|
+| `NavController` owns the back stack | You own the back stack (`SnapshotStateList`) |
+| `NavHost` renders composable destinations | `NavDisplay` observes the back stack and renders entries |
+| Routes are strings or `@Serializable` types | Keys are `@Serializable` types implementing `NavKey` |
+| Imperative navigation (`navController.navigate()`) | List manipulation (`backStack.add()`, `backStack.removeLastOrNull()`) |
+| `NavGraph` groups destinations | No separate graph — entries are resolved by the `entryProvider` |
+| Deep links parsed by Navigation library | Deep links parsed by your code — you construct the back stack |
+| Graph-scoped ViewModels via `getBackStackEntry()` | Entry-scoped ViewModels via `rememberViewModelStoreNavEntryDecorator()` |
+| `currentBackStackEntryAsState()` for selected tab | Direct back stack inspection (`backStack.last()`) |
+| `saveState`/`restoreState` for tab persistence | Persistent per-tab stacks or root swap pattern |
+
+## Migration Steps
+
+### 1. Replace route types with NavKey
+
+```kotlin
+// Nav 2
+@Serializable data object Home
+@Serializable data class Detail(val id: String)
+
+// Nav 3
+@Serializable data object Home : NavKey
+@Serializable data class Detail(val id: String) : NavKey
+```
+
+### 2. Replace NavController with a SnapshotStateList back stack
+
+```kotlin
+// Nav 2
+val navController = rememberNavController()
+navController.navigate(Detail(id))
+
+// Nav 3
+val backStack = rememberNavBackStack(Home)
+backStack.add(Detail(id))
+```
+
+### 3. Replace NavHost with NavDisplay
+
+Replace `NavHost` + `composable<T>` with `NavDisplay` + `entryProvider` + `entry<T>`. Each `composable` block becomes an `entry` block; `navController.navigate()` becomes `backStack.add()`. For full `NavDisplay` API, decorators, and DI wiring, see [navigation-3.md](navigation-3.md) and [navigation-3-di.md](navigation-3-di.md).
+
+### 4. Replace graph-scoped ViewModels with entry decorators
+
+Nav 3 scopes ViewModels to entries automatically via `rememberViewModelStoreNavEntryDecorator()`. For shared state across entries, lift state to a parent composable or use a shared ViewModel at the Activity/App scope.
+
+**Nav 2 graph-scoped pattern:**
+
+```kotlin
+val parentEntry = remember(entry) { navController.getBackStackEntry("checkout") }
+val sharedViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
+```
+
+**Nav 3 equivalent — lift to parent or share via DI:**
+
+```kotlin
+// Option 1: shared ViewModel at a higher scope
+val sharedViewModel: CheckoutViewModel = viewModel() // Activity-scoped
+
+// Option 2: state hoisting in a parent composable
+// The parent composable holds shared state, passes it to child entries
+```
+
+### 5. Replace deep link integration
+
+Nav 3 does not parse deep links — parse URIs in your platform entry point and construct the back stack manually:
+
+```kotlin
+// Nav 2
+composable<Detail>(
+    deepLinks = listOf(navDeepLink<Detail>(basePath = "https://example.com/detail"))
+) { /* ... */ }
+
+// Nav 3
+LaunchedEffect(deepLinkId) {
+    if (deepLinkId != null) {
+        backStack.clear()
+        backStack.addAll(listOf(Home, Detail(deepLinkId)))
+    }
+}
+```
+
+### 6. Replace tab navigation
+
+```kotlin
+// Nav 2 — NavigationBar + currentBackStackEntryAsState + saveState/restoreState
+navController.navigate(tab.route) {
+    popUpTo(startDest) { saveState = true }
+    launchSingleTop = true
+    restoreState = true
+}
+
+// Nav 3 — direct back stack manipulation
+while (backStack.size > 1) backStack.removeLast()
+backStack[0] = targetTopLevelKey
+```
+
+## Incremental Migration
+
+You do not have to migrate everything at once. The official docs recommend:
+
+1. **Start with leaf screens** that have simple navigation — they are the easiest to convert since they have few navigation dependencies
+2. **Move shared/graph-scoped ViewModels last** — these require the most restructuring (entry decorators replace graph scoping)
+3. **Keep Nav 2 running alongside Nav 3** during transition if needed — they can coexist in the same app
+4. **Convert navigation effects** — update ViewModel effect handlers from `navController.navigate()` calls to `backStack.add()` calls one screen at a time
+5. **Test each migrated screen** independently before moving to the next
+
+### Coexistence strategy
+
+During migration, Nav 2 and Nav 3 can coexist in the same app. Use Nav 3 for new feature modules while keeping Nav 2 for existing screens. Bridge between them at the Activity level — a Nav 2 destination can launch an Activity/Fragment that hosts Nav 3, or vice versa.

+ 91 - 0
.claude/skills/compose-skill/references/navigation.md

@@ -0,0 +1,91 @@
+# Navigation
+
+Shared navigation concepts for Nav 2 and Nav 3. Load first, then see version-specific references.
+
+References:
+- [Nav 3 official docs](https://developer.android.com/guide/navigation/navigation-3)
+- [Nav 2 official docs](https://developer.android.com/guide/navigation/get-started)
+- [Kotlin CMP Nav 3 docs](https://kotlinlang.org/docs/multiplatform/compose-navigation-3.html)
+
+## Nav 2 vs Nav 3 Decision Guide
+
+| Criterion | Nav 3 (NavDisplay) | Nav 2 (NavHost / NavController) |
+|---|---|---|
+| Back stack ownership | You own it (`SnapshotStateList`) | Library owns it (`NavController`) |
+| Navigation model | List manipulation — `add()`, `removeLastOrNull()` | Imperative — `navigate()`, `popBackStack()` |
+| MVI alignment | Natural — back stack is state you mutate | Requires bridging — controller calls in effect handlers |
+| Deep link parsing | You parse URIs, construct back stack manually | Built-in `NavDeepLink` parsing |
+| Scenes / adaptive layouts | First-class: dialog, bottom sheet, list-detail | Manual: separate composable overlays |
+| CMP support | Full (Android, iOS, Desktop, Web) | Android-only (JetBrains forks exist but differ) |
+| Maturity | Newer — verify artifact stability for production | Stable, battle-tested |
+| Fragment interop | None | Full Fragment/Activity integration |
+
+**When to use Nav 3:**
+- New Compose projects following MVI architecture
+- Compose Multiplatform projects targeting multiple platforms
+- Projects wanting direct back stack control as state
+- Projects needing adaptive layout scenes (list-detail, dialog, bottom sheet)
+
+**When to use Nav 2:**
+- Existing codebases already built on `NavHost`/`NavController`
+- Projects requiring built-in deep link parsing via `NavDeepLink`
+- Hybrid Compose + Fragment apps where Nav 2 provides Fragment integration
+- Teams that prefer the declarative `NavGraph` DSL
+
+## Navigation in MVI
+
+The architectural rule: **ViewModels emit semantic effects; the route layer handles navigation.** This rule applies identically to both Nav 2 and Nav 3.
+
+```kotlin
+sealed interface ItemEffect {
+    data object NavigateBack : ItemEffect
+    data class OpenDetails(val id: String) : ItemEffect
+}
+
+// Nav 3 route layer — manipulates back stack
+CollectEffect(viewModel.effect) { effect ->
+    when (effect) {
+        is ItemEffect.NavigateBack -> backStack.removeLastOrNull()
+        is ItemEffect.OpenDetails -> backStack.add(Details(effect.id))
+    }
+}
+
+// Nav 2 route layer — calls NavController
+CollectEffect(viewModel.effect) { effect ->
+    when (effect) {
+        is ItemEffect.NavigateBack -> navController.navigateUp()
+        is ItemEffect.OpenDetails -> navController.navigate(Detail(effect.id))
+    }
+}
+```
+
+### Rules
+
+- Never call navigation during composition — always in `LaunchedEffect` or event handler callbacks
+- Never pass the back stack (Nav 3) or `NavController` (Nav 2) to the ViewModel or leaf composables
+- ViewModel emits semantic effects (`NavigateBack`, `OpenDetails(id)`)
+- Route/navigation layer translates effects to navigation calls
+- Keep navigation logic at the route boundary, not in screens or leaves
+
+## Anti-Patterns
+
+| Anti-pattern | Applies to | Why it hurts | Better replacement |
+|---|---|---|---|
+| Navigating during composition | Both | Triggers on every recomposition, causes infinite loops | Navigate in `LaunchedEffect` or event handler callbacks |
+| Passing NavController/back stack to ViewModel | Both | Violates MVI boundary, navigation becomes business logic | ViewModel emits semantic effects; route handles navigation |
+| String-based routes without type safety | Both | No compile-time checking, argument mismatch at runtime | `@Serializable` data classes/objects |
+| Missing `onBack` handler | Nav 3 | System back gesture does nothing | Always provide `onBack = { backStack.removeLastOrNull() }` |
+| Globally-scoped ViewModel for per-screen data | Both | Data leaks across screens, not cleared on pop | Entry-scoped VMs (Nav 3 decorators) or destination-scoped VMs (Nav 2) |
+| Recreating back stacks on tab switch | Both | Loses user navigation history within tabs | Persistent per-tab stacks (Nav 3) or `saveState`/`restoreState` (Nav 2) |
+| Missing entry decorators | Nav 3 | ViewModels leak, saveable state lost | Always include both `rememberSaveableStateHolderNavEntryDecorator` and `rememberViewModelStoreNavEntryDecorator` |
+| Using Nav 2 in new MVI codebases | Nav 3 preferred | Nav 3's user-owned back stack aligns better with MVI state ownership | Prefer Nav 3 `NavDisplay` for new MVI-first projects; Nav 2 remains valid for existing codebases |
+
+## Version-Specific References
+
+Load the file that matches your task:
+
+- **Nav 3 routes, tabs, scenes, deep links, or back stack patterns** → [navigation-3.md](navigation-3.md)
+- **Nav 2 NavHost, tabs, deep links, nested graphs, or animations** → [navigation-2.md](navigation-2.md)
+- **Wiring Hilt or Koin with Nav 3** → [navigation-3-di.md](navigation-3-di.md)
+- **Wiring Hilt or Koin with Nav 2** → [navigation-2-di.md](navigation-2-di.md)
+- **Migrating from Nav 2 to Nav 3** → [navigation-migration.md](navigation-migration.md)

+ 237 - 0
.claude/skills/compose-skill/references/networking-ktor-architecture.md

@@ -0,0 +1,237 @@
+# Network Architecture Decisions
+
+Optional patterns for projects that outgrow the simple approach in [networking-ktor.md](networking-ktor.md). Use these when the project needs richer error classification, centralized request handling, or production instrumentation. For auth see [networking-ktor-auth.md](networking-ktor-auth.md). For testing see [networking-ktor-testing.md](networking-ktor-testing.md).
+
+## Error Handling Strategy
+
+Choose one approach and use it consistently across the project.
+
+### Decision: `Result<T>` vs custom sealed class
+
+| Criterion | `Result<T>` (Kotlin stdlib) | Custom `ApiResult<T>` |
+|---|---|---|
+| Operators | Built-in: `map`, `fold`, `getOrNull`, `onSuccess`, `onFailure` | Define your own |
+| Error info | `Throwable` only — inspect exception type at use site | Sealed subclasses with structured data per error kind |
+| UI branching | `when (e) { is IOException -> ... }` | `when (error) { is ApiResult.Unauthorized -> ... }` |
+| Maintenance | Zero — stdlib | Team maintains the sealed class |
+| Best for | Most apps, prototypes, APIs with few error-type branches | Apps needing per-error-type UI flows (login redirect, retry prompt, offline message) |
+
+`Result<T>` is the simpler default. A custom sealed class is justified when the UI needs to branch on many distinct error types and inspecting exception classes becomes unwieldy.
+
+### Option A — Kotlin `Result<T>`
+
+```kotlin
+suspend inline fun <reified T> HttpClient.safeRequest(
+    block: HttpRequestBuilder.() -> Unit,
+): Result<T> = runCatching { request { block() }.body<T>() }
+
+// Repository usage
+override suspend fun getItems(): Result<List<Item>> {
+    return client.safeRequest<ItemListDto> { url("items") }
+        .map { it.items.toDomain() }
+}
+
+// ViewModel consumption
+viewModelScope.launch {
+    repository.getItems()
+        .onSuccess { items -> _state.update { it.copy(items = items) } }
+        .onFailure { error ->
+            when (error) {
+                is ClientRequestException -> handleHttpError(error.response.status.value)
+                is IOException -> _state.update { it.copy(error = "No connection") }
+                else -> _state.update { it.copy(error = "Something went wrong") }
+            }
+        }
+}
+```
+
+### Option B — Custom `ApiResult<T>`
+
+```kotlin
+sealed class ApiResult<out T> {
+    data class Success<T>(val data: T) : ApiResult<T>()
+
+    sealed class Failure : ApiResult<Nothing>() {
+        data class HttpError(val code: Int, val message: String?, val serverMessage: String? = null) : Failure()
+        data class NetworkError(val message: String? = null) : Failure()
+        data class Timeout(val message: String? = null) : Failure()
+        data class Unauthorized(val serverMessage: String? = null) : Failure()
+        data class SerializationError(val message: String? = null) : Failure()
+        data class Unknown(val throwable: Throwable) : Failure()
+    }
+}
+
+inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (this) {
+    is ApiResult.Success -> ApiResult.Success(transform(data))
+    is ApiResult.Failure -> this
+}
+
+inline fun <T, R> ApiResult<T>.fold(
+    onSuccess: (T) -> R,
+    onFailure: (ApiResult.Failure) -> R,
+): R = when (this) {
+    is ApiResult.Success -> onSuccess(data)
+    is ApiResult.Failure -> onFailure(this)
+}
+
+fun <T> ApiResult<T>.getOrNull(): T? = (this as? ApiResult.Success)?.data
+```
+
+## Safe Request Wrapper
+
+A `safeRequest` extension centralizes error handling so repositories stay focused on data mapping. This is one valid project-level pattern — not required for every project.
+
+Pair with `expectSuccess = false` so the wrapper inspects status codes instead of catching Ktor's response exceptions:
+
+```kotlin
+suspend inline fun <reified T> HttpClient.safeRequest(
+    block: HttpRequestBuilder.() -> Unit,
+): ApiResult<T> {
+    return try {
+        val response = request { block() }
+        when (response.status.value) {
+            in 200..299 -> ApiResult.Success(response.body<T>())
+            else -> classifyStatus(response.status.value, tryParseError(response))
+        }
+    } catch (e: CancellationException) {
+        throw e
+    } catch (e: Exception) {
+        classifyException(e)
+    }
+}
+```
+
+For 204 No Content responses, use `Unit` as the type parameter: `safeRequest<Unit> { ... }`.
+
+### Server error message extraction
+
+Parse backend error envelopes safely — never fail if the error body is malformed:
+
+```kotlin
+@Serializable
+data class ErrorDto(
+    val message: String? = null,
+    val error: String? = null,
+    val detail: String? = null,
+) {
+    val displayMessage: String? get() = message ?: error ?: detail
+}
+
+suspend fun tryParseError(response: HttpResponse): String? = runCatching {
+    response.body<ErrorDto>().displayMessage
+}.getOrNull()
+```
+
+## Exception Classification
+
+Map Ktor exceptions to error types. Used inside `safeRequest` with `ApiResult`, or at the ViewModel level with `Result<T>`.
+
+```kotlin
+fun classifyException(e: Exception): ApiResult.Failure = when (e) {
+    is HttpRequestTimeoutException,
+    is ConnectTimeoutException,
+    is SocketTimeoutException,
+    -> ApiResult.Failure.Timeout("Request timed out")
+
+    is IOException,
+    is UnresolvedAddressException,
+    -> ApiResult.Failure.NetworkError("No internet connection")
+
+    is SerializationException,
+    is JsonConvertException,
+    is MissingFieldException,
+    -> ApiResult.Failure.SerializationError("Invalid response format")
+
+    is ClientRequestException -> when (e.response.status.value) {
+        401 -> ApiResult.Failure.Unauthorized()
+        else -> ApiResult.Failure.HttpError(e.response.status.value, "Request failed")
+    }
+
+    is ServerResponseException -> ApiResult.Failure.HttpError(
+        e.response.status.value, "Server error",
+    )
+
+    else -> ApiResult.Failure.Unknown(e)
+}
+
+fun classifyStatus(code: Int, serverMessage: String? = null): ApiResult.Failure = when (code) {
+    401 -> ApiResult.Failure.Unauthorized(serverMessage)
+    403 -> ApiResult.Failure.HttpError(code, "Access denied", serverMessage)
+    404 -> ApiResult.Failure.HttpError(code, "Not found", serverMessage)
+    429 -> ApiResult.Failure.HttpError(code, "Too many requests", serverMessage)
+    in 400..599 -> ApiResult.Failure.HttpError(code, if (code < 500) "Request failed" else "Server error", serverMessage)
+    else -> ApiResult.Failure.HttpError(code, "Unexpected error", serverMessage)
+}
+```
+
+`CancellationException` must always be re-thrown — never swallow it. It breaks structured concurrency.
+
+## Plugin Composition
+
+### What goes where
+
+| Concern | Where | Why |
+|---|---|---|
+| Base URL, content type, static headers | `defaultRequest {}` | Runs per-request, reads live state |
+| JSON parsing | `ContentNegotiation` | Core plugin |
+| Timeouts | `HttpTimeout` | Default for every project |
+| Logging | `Logging` | Debug aid — sanitize `Authorization` in production |
+| Token load and refresh | `Auth` plugin | Built-in retry cycle — see [networking-ktor-auth.md](networking-ktor-auth.md) |
+| Retry on server errors | `HttpRequestRetry` | Add when the API has transient failures worth retrying |
+| Compression | `ContentEncoding` | Add for bandwidth-sensitive APIs |
+
+### Plugin install order
+
+Install order matters — plugins execute in installation order for requests, reverse order for responses.
+
+```
+ContentNegotiation → Auth → HttpRequestRetry → HttpTimeout → ContentEncoding
+```
+
+Install `HttpRequestRetry` before `HttpTimeout` so retries work on timeout errors. `Auth` handles 401s independently from `HttpRequestRetry` — keep these concerns separate.
+
+## Custom Client Plugins
+
+*Advanced — use when built-in plugins don't cover the need.*
+
+Build reusable interceptors with `createClientPlugin` for analytics, header injection, or response logging:
+
+```kotlin
+val ApiKeyPlugin = createClientPlugin("ApiKeyPlugin", ::ApiKeyConfig) {
+    val apiKey = pluginConfig.apiKey
+
+    onRequest { request, _ ->
+        request.headers.append("X-Api-Key", apiKey)
+    }
+}
+
+class ApiKeyConfig {
+    var apiKey: String = ""
+}
+
+val client = HttpClient(engine) {
+    install(ApiKeyPlugin) {
+        apiKey = "my-secret-key"
+    }
+}
+```
+
+For global response observation (analytics, session expiry), use `onResponse` in a similar plugin without changing error handling.
+
+## Debug vs Production Logging
+
+| Concern | Debug | Production |
+|---|---|---|
+| Ktor `Logging` plugin | `LogLevel.BODY` | `LogLevel.HEADERS` or not installed |
+| `sanitizeHeader` | Optional | Required for `Authorization` |
+
+## Anti-Patterns
+
+| Anti-pattern | Why it hurts | Better approach |
+|---|---|---|
+| `HttpClient` per request | Connection pool waste, resource leaks | Shared singleton via DI |
+| Swallowing `CancellationException` | Breaks structured concurrency, coroutine never cancels | Re-throw explicitly |
+| Logging request bodies in production | Leaks sensitive data (tokens, PII) | `LogLevel.HEADERS` or off; `sanitizeHeader` for auth |
+| Mixing `expectSuccess = true` with manual status inspection | `ClientRequestException` thrown before you inspect status | Pick one: `expectSuccess = true` + catch exceptions, or `false` + check `response.status` |
+| Random plugin install order | Retries fire before timeout, auth conflicts with retry | Follow documented composition order |
+| Forced specific result wrapper | Doesn't adapt to team conventions or project scale | Present `Result`/`ApiResult` as a project decision |

+ 204 - 0
.claude/skills/compose-skill/references/networking-ktor-auth.md

@@ -0,0 +1,204 @@
+# Networking — Auth, WebSockets & SSE
+
+Bearer token auth, WebSocket messaging, and Server-Sent Events for Ktor client. For core HttpClient setup see [networking-ktor.md](networking-ktor.md). For testing see [networking-ktor-testing.md](networking-ktor-testing.md).
+
+References:
+- [Ktor bearer auth](https://ktor.io/docs/client-bearer-auth.html)
+- [Ktor WebSockets](https://ktor.io/docs/client-websockets.html)
+- [Ktor SSE](https://ktor.io/docs/client-server-sent-events.html)
+
+## Bearer Token Auth
+
+Use Ktor's `Auth` plugin with `bearer` for token management. The plugin handles loading cached tokens, attaching them to requests, and refreshing on 401 automatically.
+
+### Default approach — `markAsRefreshTokenRequest()`
+
+The Ktor-documented pattern uses `markAsRefreshTokenRequest()` inside `refreshTokens` so the refresh request itself is not intercepted by the auth plugin. This avoids circular auth loops without needing a separate client.
+
+```kotlin
+fun createAuthenticatedClient(
+    engine: HttpClientEngine,
+    baseUrl: String,
+    tokenStorage: TokenStorage,
+    onSessionExpired: () -> Unit,
+): HttpClient {
+    return HttpClient(engine) {
+        install(ContentNegotiation) {
+            json(Json { ignoreUnknownKeys = true })
+        }
+
+        defaultRequest { url(baseUrl) }
+
+        install(Auth) {
+            bearer {
+                loadTokens {
+                    val tokens = tokenStorage.getTokens()
+                    BearerTokens(tokens.accessToken, tokens.refreshToken)
+                }
+
+                refreshTokens {
+                    val refreshToken = oldTokens?.refreshToken
+                        ?: return@refreshTokens null
+
+                    try {
+                        markAsRefreshTokenRequest()
+                        val response = client.post("auth/refresh") {
+                            contentType(ContentType.Application.Json)
+                            setBody(RefreshRequest(refreshToken))
+                        }.body<TokenResponse>()
+
+                        tokenStorage.saveTokens(response.accessToken, response.refreshToken)
+                        BearerTokens(response.accessToken, response.refreshToken)
+                    } catch (e: Exception) {
+                        onSessionExpired()
+                        null
+                    }
+                }
+
+                sendWithoutRequest { request ->
+                    request.url.pathSegments.none { it in listOf("login", "register") }
+                }
+            }
+        }
+    }
+}
+```
+
+**Key points:**
+- `markAsRefreshTokenRequest()` — prevents the refresh call from being intercepted by the `Auth` plugin, avoiding infinite loops.
+- `oldTokens` — provided by Ktor's `RefreshTokensParams` receiver, gives access to the expired tokens.
+- `sendWithoutRequest` — controls which endpoints skip authentication entirely (login, register, public endpoints).
+- Return `null` from `refreshTokens` to signal that refresh failed — Ktor will not retry the original request.
+
+### TokenStorage interface
+
+Implement with DataStore, encrypted SharedPreferences, or Keychain depending on platform. The interface uses app-owned types — convert to `BearerTokens` only at the plugin boundary.
+
+```kotlin
+interface TokenStorage {
+    suspend fun getTokens(): AuthTokens
+    suspend fun saveTokens(accessToken: String, refreshToken: String)
+    suspend fun clearTokens()
+}
+
+data class AuthTokens(val accessToken: String, val refreshToken: String)
+```
+
+## Advanced: Isolated Refresh Client
+
+Some teams prefer a dedicated `HttpClient` for the refresh call — one with no `Auth` plugin installed — to guarantee the refresh request cannot trigger another auth cycle. This is a valid alternative when the team wants explicit separation, but `markAsRefreshTokenRequest()` achieves the same goal with less ceremony.
+
+```kotlin
+private suspend fun refreshBearerToken(
+    baseUrl: String,
+    tokenStorage: TokenStorage,
+    onSessionExpired: () -> Unit,
+): BearerTokens? {
+    val tokens = tokenStorage.getTokens()
+    val refreshToken = tokens.refreshToken.ifBlank { null } ?: return null
+    return try {
+        HttpClient {
+            install(ContentNegotiation) { json() }
+        }.use { refreshClient ->
+            val response = refreshClient.post(baseUrl + "auth/refresh") {
+                contentType(ContentType.Application.Json)
+                setBody(RefreshRequest(refreshToken))
+            }.body<TokenResponse>()
+            tokenStorage.saveTokens(response.accessToken, response.refreshToken)
+            BearerTokens(response.accessToken, response.refreshToken)
+        }
+    } catch (e: Exception) {
+        onSessionExpired()
+        null
+    }
+}
+```
+
+If using this pattern, call it from inside `refreshTokens` instead of using `client` directly. Close the refresh client after use (`.use {}` handles this).
+
+## WebSocket Support
+
+### Dependencies
+
+Add `ktor-client-websockets` to your version catalog and `commonMain` dependencies.
+
+### Connection and messaging
+
+```kotlin
+val client = HttpClient(engine) {
+    install(WebSockets) {
+        pingIntervalMillis = 30_000
+    }
+}
+
+client.webSocket("wss://api.example.com/ws") {
+    send(Frame.Text(Json.encodeToString(SubscribeMessage("items"))))
+
+    for (frame in incoming) {
+        when (frame) {
+            is Frame.Text -> {
+                val message = Json.decodeFromString<ServerMessage>(frame.readText())
+                // handle message
+            }
+            is Frame.Close -> break
+            else -> Unit
+        }
+    }
+}
+```
+
+### Session reference for external control
+
+```kotlin
+val session = client.webSocketSession("wss://api.example.com/ws")
+session.send(Frame.Text("hello"))
+val response = session.incoming.receive() as Frame.Text
+session.close()
+```
+
+### Serialization converter
+
+Type-safe WebSocket messaging using kotlinx.serialization:
+
+```kotlin
+install(WebSockets) {
+    contentConverter = KotlinxWebsocketSerializationConverter(Json)
+}
+
+client.webSocket("wss://api.example.com/ws") {
+    sendSerialized(SubscribeMessage("items"))
+    val message = receiveDeserialized<ServerMessage>()
+}
+```
+
+## Server-Sent Events (SSE)
+
+SSE provides server-push updates over HTTP. Unlike WebSockets, SSE is unidirectional (server to client) and works over standard HTTP. SSE support is built into `ktor-client-core` — no extra dependency needed.
+
+### Basic usage
+
+```kotlin
+val client = HttpClient(engine) {
+    install(SSE)
+}
+
+client.sse("https://api.example.com/events") {
+    incoming.collect { event ->
+        println("Event: ${event.event}")
+        println("Data: ${event.data}")
+        println("ID: ${event.id}")
+    }
+}
+```
+
+### When to use SSE vs WebSocket
+
+| Criterion | SSE | WebSocket |
+|---|---|---|
+| Direction | Server -> Client only | Bidirectional |
+| Protocol | HTTP (standard) | WebSocket (protocol upgrade) |
+| Auto-reconnect | Built-in | Manual |
+| Binary data | No (text only) | Yes |
+| Use case | Live feeds, notifications, progress, streaming AI | Chat, gaming, real-time collaboration |
+
+Prefer SSE for server-push scenarios. Use WebSockets when the client also needs to send frequent messages.

+ 153 - 0
.claude/skills/compose-skill/references/networking-ktor-testing.md

@@ -0,0 +1,153 @@
+# Networking — Testing & DI
+
+MockEngine testing patterns and Koin/Hilt DI integration for Ktor client. For core HttpClient setup see [networking-ktor.md](networking-ktor.md). For error handling patterns see [networking-ktor-architecture.md](networking-ktor-architecture.md).
+
+References:
+- [Ktor testing](https://ktor.io/docs/client-testing.html)
+- [Ktor MockEngine](https://api.ktor.io/ktor-client-mock/io.ktor.client.engine.mock/-mock-engine/index.html)
+
+## Testing with MockEngine
+
+### Setup
+
+```kotlin
+// commonTest
+testImplementation("io.ktor:ktor-client-mock:$ktor_version")
+```
+
+### Testing API calls
+
+```kotlin
+@Test
+fun `getItem returns mapped domain model`() = runTest {
+    val mockEngine = MockEngine { request ->
+        assertEquals("/items/123", request.url.encodedPath)
+        respond(
+            content = """{"id":"123","name":"Test","status":"active","created_at":1700000000}""",
+            status = HttpStatusCode.OK,
+            headers = headersOf(HttpHeaders.ContentType, "application/json"),
+        )
+    }
+
+    val client = createHttpClient(mockEngine, "https://api.example.com/")
+    val repo = ItemRepositoryImpl(ItemApi(client))
+    val result = repo.getItem("123")
+    assertEquals("Test", result.name)
+}
+```
+
+### Testing error handling
+
+```kotlin
+@Test
+fun `getItem throws on 404`() = runTest {
+    val mockEngine = MockEngine {
+        respond(content = """{"error":"not found"}""", status = HttpStatusCode.NotFound)
+    }
+    val client = HttpClient(mockEngine) {
+        expectSuccess = true
+        install(ContentNegotiation) { json() }
+    }
+    val api = ItemApi(client)
+    assertFailsWith<ClientRequestException> { api.getItem("999") }
+}
+```
+
+If using a `safeRequest` wrapper (see [networking-ktor-architecture.md](networking-ktor-architecture.md)), test the wrapper's return type instead:
+
+```kotlin
+@Test
+fun `safeRequest returns failure on 404`() = runTest {
+    val mockEngine = MockEngine {
+        respond(content = """{"error":"not found"}""", status = HttpStatusCode.NotFound)
+    }
+    val client = HttpClient(mockEngine) {
+        expectSuccess = false
+        install(ContentNegotiation) { json() }
+    }
+    val result = client.safeRequest<ItemDto> { url("items/999") }
+    assertTrue(result.isFailure) // or check sealed class variant
+}
+```
+
+### Request assertions
+
+Verify request method, headers, body, and query parameters:
+
+```kotlin
+@Test
+fun `createItem sends correct request`() = runTest {
+    val mockEngine = MockEngine { request ->
+        assertEquals(HttpMethod.Post, request.method)
+        assertEquals("application/json", request.body.contentType?.toString())
+
+        val body = (request.body as TextContent).text
+        assertTrue(body.contains("\"name\":\"Widget\""))
+
+        respond(
+            content = """{"id":"1","name":"Widget","status":"active","created_at":1700000000}""",
+            status = HttpStatusCode.Created,
+            headers = headersOf(HttpHeaders.ContentType, "application/json"),
+        )
+    }
+
+    val client = createHttpClient(mockEngine, "https://api.example.com/")
+    val api = ItemApi(client)
+    val result = api.createItem(CreateItemRequest(name = "Widget"))
+    assertEquals("Widget", result.name)
+}
+```
+
+### Multiple responses
+
+MockEngine can return different responses based on path:
+
+```kotlin
+val mockEngine = MockEngine { request ->
+    when (request.url.encodedPath) {
+        "/items" -> respond(
+            content = """{"items":[],"total":0}""",
+            headers = headersOf(HttpHeaders.ContentType, "application/json"),
+        )
+        "/items/1" -> respond(
+            content = """{"id":"1","name":"Test","status":"active","created_at":1700000000}""",
+            headers = headersOf(HttpHeaders.ContentType, "application/json"),
+        )
+        else -> respondError(HttpStatusCode.NotFound)
+    }
+}
+```
+
+### Engine injection for testability
+
+Accept `HttpClientEngine` as a constructor parameter so you can inject `MockEngine` in tests:
+
+```kotlin
+// Production: ItemApi(createHttpClient(OkHttp.create(), baseUrl))
+// Test:       ItemApi(createHttpClient(MockEngine { ... }, baseUrl))
+```
+
+Share the same `createHttpClient` factory in production and tests to keep plugin configuration consistent.
+
+## DI Integration
+
+Provide `HttpClient` and `HttpClientEngine` as singletons. Use `expect/actual` platform modules for engine selection (OkHttp on Android, Darwin on iOS):
+
+```kotlin
+// Koin: single { createHttpClient(engine = get(), baseUrl = "https://api.example.com/") }
+// Hilt: @Provides @Singleton fun provideHttpClient(): HttpClient = createHttpClient(...)
+```
+
+For full Koin module patterns (including platform engine modules), see [koin.md](koin.md). For Hilt module patterns, see [hilt.md](hilt.md).
+
+## Anti-Patterns
+
+| Anti-pattern | Why it hurts | Better replacement |
+|---|---|---|
+| DTOs used directly in UI state | UI coupled to API contract, breaks on API changes | Map to domain models at repository boundary |
+| Network calls in composables | Violates UDF, untestable, reruns on recomposition | Call from ViewModel, expose via StateFlow |
+| No timeout configuration | Requests hang indefinitely on bad networks | Set `connectTimeoutMillis`, `requestTimeoutMillis`, `socketTimeoutMillis` |
+| Hardcoded base URLs | Can't switch environments (dev/staging/prod) | Inject base URL via config or DI |
+| Parsing/mapping in the API service | Mixes concerns, harder to test | API service returns DTOs; repository maps to domain |
+| Creating a new `HttpClient` per test | Tests miss plugin-config mismatches | Use the same `createHttpClient` factory with `MockEngine` |
+| No compression | Wastes bandwidth on text-heavy APIs | `install(ContentEncoding) { gzip() }` |

+ 270 - 0
.claude/skills/compose-skill/references/networking-ktor.md

@@ -0,0 +1,270 @@
+# Networking with Ktor Client
+
+Default Ktor client setup for Compose Multiplatform and Android projects. Advanced topics in separate files:
+
+- [Architecture decisions](networking-ktor-architecture.md) — result wrappers, error classification, plugin composition *(optional)*
+- [Auth, WebSockets & SSE](networking-ktor-auth.md) — bearer tokens, realtime *(use when needed)*
+- [Testing & DI](networking-ktor-testing.md) — MockEngine, Koin/Hilt wiring
+
+References:
+- [Ktor client overview](https://ktor.io/docs/client.html)
+- [Ktor client plugins](https://ktor.io/docs/client-plugins.html)
+- [Ktor content negotiation](https://ktor.io/docs/client-serialization.html)
+
+## Dependencies and Platform Engines
+
+### Version catalog
+
+```toml
+[versions]
+ktor = "<latest>"    # verify: https://ktor.io/docs/releases.html or Maven Central
+
+[libraries]
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
+ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
+ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
+ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
+ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
+ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
+```
+
+As needed: `ktor-client-auth`, `ktor-client-websockets`, `ktor-client-resources`, `ktor-client-encoding` (same `version.ref = "ktor"` pattern).
+
+### build.gradle.kts
+
+```kotlin
+commonMain.dependencies {
+    implementation(libs.ktor.client.core)
+    implementation(libs.ktor.client.content.negotiation)
+    implementation(libs.ktor.serialization.kotlinx.json)
+    implementation(libs.ktor.client.logging)
+}
+
+androidMain.dependencies {
+    implementation(libs.ktor.client.okhttp)
+}
+
+iosMain.dependencies {
+    implementation(libs.ktor.client.darwin)
+}
+
+jvmMain.dependencies {
+    implementation(libs.ktor.client.cio)
+}
+
+commonTest.dependencies {
+    implementation(libs.ktor.client.mock)
+}
+```
+
+### Platform engine selection
+
+| Platform | Engine | Dependency |
+|---|---|---|
+| Android | OkHttp | `ktor-client-okhttp` |
+| iOS | Darwin (NSURLSession) | `ktor-client-darwin` |
+| JVM/Desktop | CIO | `ktor-client-cio` |
+| All (testing) | MockEngine | `ktor-client-mock` |
+
+For CMP, select the engine per source set. For Android-only, use OkHttp directly.
+
+## HttpClient Configuration
+
+Create a single, reusable `HttpClient` instance. Never create one per request.
+
+```kotlin
+fun createHttpClient(engine: HttpClientEngine, baseUrl: String): HttpClient {
+    return HttpClient(engine) {
+        install(ContentNegotiation) {
+            json(Json {
+                ignoreUnknownKeys = true      // ignore unknown JSON fields
+                coerceInputValues = true        // null → defaults for non-null props
+                encodeDefaults = true         // include defaults when serializing
+            })
+        }
+
+        defaultRequest {
+            url(baseUrl)
+            headers.append("Accept", "application/json")
+        }
+
+        install(HttpTimeout) {
+            connectTimeoutMillis = 15_000
+            requestTimeoutMillis = 30_000
+            socketTimeoutMillis = 15_000
+        }
+
+        install(Logging) {
+            logger = Logger.DEFAULT
+            level = LogLevel.HEADERS
+            sanitizeHeader { it == "Authorization" }
+        }
+    }
+}
+```
+
+This is the minimal production-ready client. Add plugins incrementally when the project needs them — auth, retry, compression, and content encoding are covered in the sub-references.
+
+Set `isLenient = true` only for non-standard APIs; it accepts malformed JSON and can hide data issues in production.
+
+### `expectSuccess` — choose based on error strategy
+
+| Setting | Behavior | Use when |
+|---|---|---|
+| `true` (Ktor default) | Throws `ClientRequestException` / `ServerResponseException` on non-2xx | Using `try/catch` or `runCatching` for error handling |
+| `false` | Returns the response regardless of status | Inspecting `response.status` manually in a custom wrapper |
+
+Both are valid. Pick one approach and apply it consistently. See [networking-ktor-architecture.md](networking-ktor-architecture.md) for wrapper patterns that pair with `expectSuccess = false`.
+
+## DTO Models and Serialization
+
+```kotlin
+@Serializable
+data class ItemListDto(
+    val items: List<ItemDto>,
+    val total: Int,
+    @SerialName("next_page") val nextPage: String? = null,
+)
+
+@Serializable
+data class ItemDto(
+    val id: String,
+    val name: String,
+    val status: StatusDto = StatusDto.ACTIVE,
+    @SerialName("created_at") val createdAt: Long,
+)
+
+@Serializable
+enum class StatusDto {
+    @SerialName("active") ACTIVE,
+    @SerialName("archived") ARCHIVED,
+}
+```
+
+Always `@Serializable` on DTOs, `@SerialName` when JSON keys differ, default values for optional fields. DTOs mirror the API contract — no business logic.
+
+## DTO-to-Domain Mappers
+
+Map at the repository boundary. Domain models have no serialization annotations.
+
+```kotlin
+data class Item(val id: String, val name: String, val status: ItemStatus, val createdAt: Long)
+enum class ItemStatus { ACTIVE, ARCHIVED }
+
+fun ItemDto.toDomain() = Item(
+    id = id,
+    name = name,
+    status = ItemStatus.valueOf(status.name),
+    createdAt = createdAt,
+)
+
+fun List<ItemDto>.toDomain() = map { it.toDomain() }
+```
+
+## API Service Layer
+
+Wrap `HttpClient` in a service class with typed methods:
+
+```kotlin
+class ItemApi(private val client: HttpClient) {
+
+    suspend fun getItems(page: Int = 1, limit: Int = 20): ItemListDto {
+        return client.get("items") {
+            parameter("page", page)
+            parameter("limit", limit)
+        }.body()
+    }
+
+    suspend fun getItem(id: String): ItemDto = client.get("items/$id").body()
+
+    suspend fun createItem(request: CreateItemRequest): ItemDto {
+        return client.post("items") {
+            contentType(ContentType.Application.Json)
+            setBody(request)
+        }.body()
+    }
+
+    suspend fun deleteItem(id: String) { client.delete("items/$id") }
+}
+
+@Serializable
+data class CreateItemRequest(val name: String)
+```
+
+## Repository Pattern
+
+The repository maps DTOs to domain models and handles errors. The error-handling approach is a project decision — see [networking-ktor-architecture.md](networking-ktor-architecture.md) for `Result<T>` vs custom sealed class options.
+
+### Simple approach — exceptions bubble up
+
+```kotlin
+interface ItemRepository {
+    suspend fun getItems(): List<Item>
+    suspend fun getItem(id: String): Item
+}
+
+class ItemRepositoryImpl(private val api: ItemApi) : ItemRepository {
+
+    override suspend fun getItems(): List<Item> {
+        return api.getItems().items.toDomain()
+    }
+
+    override suspend fun getItem(id: String): Item {
+        return api.getItem(id).toDomain()
+    }
+}
+```
+
+The ViewModel catches exceptions and updates state. This works well for simpler apps. For `Result` / richer error classification, see [networking-ktor-architecture.md](networking-ktor-architecture.md).
+
+### Offline-first pattern
+
+Local DB as the single source of truth. Repository syncs remote data into local storage. UI observes the local `Flow`.
+
+```kotlin
+class OfflineFirstItemRepository(
+    private val api: ItemApi,
+    private val dao: ItemDao,
+) : ItemRepository {
+
+    val items: Flow<List<Item>> = dao.observeAll().map { it.map { e -> e.toDomain() } }
+
+    suspend fun refresh() {
+        val remote = api.getItems().items
+        dao.replaceAll(remote.map { it.toEntity() })
+    }
+}
+```
+
+## Type-Safe Resources (Optional)
+
+The Ktor Resources plugin maps `@Resource`-annotated data classes to HTTP paths for compile-time URL safety. Add `ktor-client-resources` to the catalog and `implementation(libs.ktor.client.resources)`; `install(Resources)` alongside `ContentNegotiation`. Reference: [Ktor type-safe requests](https://ktor.io/docs/client-resources.html).
+
+```kotlin
+import io.ktor.resources.*
+import kotlinx.serialization.Serializable
+
+@Serializable
+@Resource("/articles")
+class Articles {
+    @Serializable
+    @Resource("{id}")
+    class ById(val parent: Articles = Articles(), val id: Int)
+
+    @Serializable
+    @Resource("search")
+    class Search(val parent: Articles = Articles(), val query: String, val page: Int = 1)
+}
+
+// Nested paths and query params resolve from the resource tree, e.g. /articles/42, /articles/search?query=compose&page=1
+val articles: List<ArticleDto> = client.get(Articles()).body()
+val article: ArticleDto = client.get(Articles.ById(id = 42)).body()
+val results: ArticleListDto = client.get(Articles.Search(query = "compose")).body()
+val created: ArticleDto = client.post(Articles()) {
+    contentType(ContentType.Application.Json)
+    setBody(CreateArticleRequest(title = "New Article"))
+}.body()
+client.delete(Articles.ById(id = 42))
+```

+ 150 - 0
.claude/skills/compose-skill/references/paging-mvi-testing.md

@@ -0,0 +1,150 @@
+# Paging 3 — MVI Integration & Testing
+
+MVI dual-flow pattern for paging, testing strategies, and anti-patterns. This file builds on the core Paging setup in [paging.md](paging.md).
+
+References:
+- [Paging testing](https://developer.android.com/topic/libraries/architecture/paging/test)
+
+## MVI Integration
+
+PagingData must be a **separate Flow** from the MVI ViewModel state. The ViewModel handles non-paging concerns (filters, selection mode, errors). PagingData flows independently.
+
+```kotlin
+class ItemListViewModel(
+    private val repository: ItemRepository,
+) : ViewModel() {
+
+    // MVI state — non-paging concerns
+    private val _state = MutableStateFlow(ItemListState())
+    val state: StateFlow<ItemListState> = _state.asStateFlow()
+
+    // PagingData — separate Flow, reacts to filter changes
+    private val _statusFilter = MutableStateFlow(StatusFilter.ALL)
+
+    val items: Flow<PagingData<ItemUi>> = _statusFilter
+        .distinctUntilChanged()
+        .flatMapLatest { status ->
+            Pager(
+                config = PagingConfig(pageSize = 20),
+                pagingSourceFactory = { repository.itemPagingSource(status) },
+            ).flow.map { pagingData -> pagingData.map { it.toUi() } }
+        }
+        .cachedIn(viewModelScope)
+
+    fun onEvent(event: ItemListEvent) {
+        when (event) {
+            is ItemListEvent.FilterChanged -> {
+                _statusFilter.value = event.filter
+                _state.update { it.copy(selectedFilter = event.filter) }
+            }
+            is ItemListEvent.ItemClicked -> {
+                // emit navigation effect
+            }
+            is ItemListEvent.SelectionToggled -> {
+                _state.update { it.copy(selectedIds = it.selectedIds.toggle(event.id)) }
+            }
+        }
+    }
+}
+```
+
+### Route collects both flows
+
+The route composable collects both the MVI state and PagingData flow, then passes them to the stateless screen composable. Use a DI-agnostic ViewModel parameter.
+
+```kotlin
+@Composable
+fun ItemListRoute(viewModel: ItemListViewModel) {
+    val state by viewModel.state.collectAsStateWithLifecycle()
+    val pagingItems = viewModel.items.collectAsLazyPagingItems()
+
+    ItemListScreen(
+        state = state,
+        pagingItems = pagingItems,
+        onEvent = viewModel::onEvent,
+    )
+}
+```
+
+The screen composable is dumb — it receives `LazyPagingItems` and state as props, emits events as callbacks.
+
+## Testing
+
+### PagingSource unit test
+
+```kotlin
+@Test
+fun `load returns page of items`() = runTest {
+    val mockApi = MockItemApi(items = listOf(item1, item2))
+    val pagingSource = ItemPagingSource(api = mockApi, query = "")
+
+    val result = pagingSource.load(
+        PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
+    )
+
+    assertTrue(result is PagingSource.LoadResult.Page)
+    val page = result as PagingSource.LoadResult.Page
+    assertEquals(2, page.data.size)
+    assertEquals(null, page.prevKey)
+    assertEquals(2, page.nextKey)
+}
+
+@Test
+fun `load returns error on network failure`() = runTest {
+    val mockApi = MockItemApi(error = IOException("Network error"))
+    val pagingSource = ItemPagingSource(api = mockApi, query = "")
+
+    val result = pagingSource.load(
+        PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
+    )
+
+    assertTrue(result is PagingSource.LoadResult.Error)
+}
+```
+
+### Testing with asSnapshot
+
+```kotlin
+@Test
+fun `items flow loads first two pages`() = runTest {
+    val viewModel = ItemListViewModel(FakeRepository())
+
+    val items = viewModel.items.asSnapshot {
+        scrollTo(index = 30)
+    }
+
+    assertTrue(items.size >= 30)
+    assertEquals("item_1", items.first().id)
+}
+```
+
+### Testing transformations
+
+```kotlin
+@Test
+fun `paging data maps dto to ui model`() = runTest {
+    val dtos = listOf(ItemDto(id = "1", title = "Test", amount = 100.0))
+    val pagingSource = dtos.asPagingSourceFactory().invoke()
+
+    val pager = TestPager(PagingConfig(pageSize = 10), pagingSource)
+    val result = pager.refresh() as PagingSource.LoadResult.Page
+
+    assertEquals(1, result.data.size)
+    assertEquals("1", result.data.first().id)
+}
+```
+
+## Anti-Patterns
+
+| Anti-pattern | Why it hurts | Fix |
+|---|---|---|
+| `PagingData` inside `UiState` StateFlow | Any non-paging state change re-emits the wrapping StateFlow, creating a new flow for `collectAsLazyPagingItems()` and resetting scroll position ([official codelab](https://github.com/android/codelab-android-paging) uses separate flows) | Expose PagingData as **separate** `Flow` |
+| New `Pager` per recomposition | Duplicate network requests, lost pagination state | Store `Flow` as `val` in ViewModel |
+| Reusing `PagingSource` instance | Crash: "PagingSource was re-used" | Always create new instance in `pagingSourceFactory` |
+| Missing `cachedIn(viewModelScope)` | Data lost on config change, duplicate loads | Always call `cachedIn` |
+| Missing list keys | Scroll jumps, state corruption on updates | `itemKey { it.id }` with stable domain IDs |
+| `combine` on `PagingData` flows | "Collecting from multiple PagingData concurrently" error | Use `flatMapLatest` for parameter changes |
+| Calling `refresh()` in composable body | Infinite refresh loop on every recomposition | Call from event handler or `LaunchedEffect` |
+| No `LoadState` handling | Broken UX: no loading indicator, no error recovery | Handle `refresh`, `append`, `prepend` states |
+| Transformations after `cachedIn` | Transformations lost on cache hit | Apply `.map { }` / `.filter { }` **before** `cachedIn` |
+| Catching generic `Exception` in PagingSource | Hides bugs, swallows unexpected errors | Catch `IOException`, `HttpException` specifically |

+ 130 - 0
.claude/skills/compose-skill/references/paging-offline.md

@@ -0,0 +1,130 @@
+# Paging 3 — Offline-First with RemoteMediator
+
+Room as the single source of truth, network as the refresh trigger. This file builds on the core Paging setup in [paging.md](paging.md).
+
+References:
+- [Network + database paging](https://developer.android.com/topic/libraries/architecture/paging/v3-network-db)
+
+## RemoteMediator.initialize
+
+Override `initialize()` to control whether RemoteMediator triggers a remote refresh on first load. This determines if cached data is shown immediately or if a network request fires first.
+
+```kotlin
+@OptIn(ExperimentalPagingApi::class)
+override suspend fun initialize(): InitializeAction {
+    val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)
+    val lastUpdated = db.remoteKeyDao().getLastUpdated("items") ?: 0L
+
+    return if (System.currentTimeMillis() - lastUpdated < cacheTimeout) {
+        InitializeAction.SKIP_INITIAL_REFRESH
+    } else {
+        InitializeAction.LAUNCH_INITIAL_REFRESH
+    }
+}
+```
+
+| Return value | Behavior |
+|---|---|
+| `LAUNCH_INITIAL_REFRESH` | Triggers `REFRESH` load immediately — fetches fresh data from network before showing cached data. **Default** if `initialize()` is not overridden. |
+| `SKIP_INITIAL_REFRESH` | Shows cached Room data immediately, only fetches from network on user-triggered refresh or append. Use when cache is still fresh. |
+
+## RemoteMediator Implementation
+
+```kotlin
+@OptIn(ExperimentalPagingApi::class)
+class ItemRemoteMediator(
+    private val api: ItemApi,
+    private val db: AppDatabase,
+) : RemoteMediator<Int, ItemEntity>() {
+
+    override suspend fun initialize(): InitializeAction {
+        val lastUpdated = db.remoteKeyDao().getLastUpdated("items") ?: 0L
+        val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)
+        return if (System.currentTimeMillis() - lastUpdated < cacheTimeout) {
+            InitializeAction.SKIP_INITIAL_REFRESH
+        } else {
+            InitializeAction.LAUNCH_INITIAL_REFRESH
+        }
+    }
+
+    override suspend fun load(
+        loadType: LoadType,
+        state: PagingState<Int, ItemEntity>,
+    ): MediatorResult {
+        val page = when (loadType) {
+            LoadType.REFRESH -> 1
+            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
+            LoadType.APPEND -> {
+                val remoteKey = db.remoteKeyDao().getRemoteKey("items")
+                remoteKey?.nextPage ?: return MediatorResult.Success(endOfPaginationReached = true)
+            }
+        }
+
+        return try {
+            val response = api.getItems(page = page, limit = state.config.pageSize)
+
+            db.withTransaction {
+                if (loadType == LoadType.REFRESH) {
+                    db.itemDao().clearAll()
+                    db.remoteKeyDao().delete("items")
+                }
+                db.itemDao().insertAll(response.items.map { it.toEntity() })
+                db.remoteKeyDao().insert(
+                    RemoteKey(
+                        id = "items",
+                        nextPage = if (response.items.isEmpty()) null else page + 1,
+                        lastUpdated = System.currentTimeMillis(),
+                    )
+                )
+            }
+
+            MediatorResult.Success(endOfPaginationReached = response.items.isEmpty())
+        } catch (e: IOException) {
+            MediatorResult.Error(e)
+        } catch (e: HttpException) {
+            MediatorResult.Error(e)
+        }
+    }
+}
+```
+
+## Pager Wiring
+
+```kotlin
+@OptIn(ExperimentalPagingApi::class)
+val items: Flow<PagingData<ItemEntity>> = Pager(
+    config = PagingConfig(pageSize = 20),
+    remoteMediator = ItemRemoteMediator(api, db),
+    pagingSourceFactory = { db.itemDao().pagingSource() },
+).flow.cachedIn(viewModelScope)
+```
+
+The `PagingSource` reads from Room. The `RemoteMediator` fetches from network and writes to Room. The UI observes the Room-backed `PagingSource`.
+
+**LoadState with RemoteMediator:** use `loadState.source.refresh` (not `loadState.refresh`) in UI code. The convenience `loadState.refresh` may report network completion before Room finishes writing, causing the loading indicator to disappear too early. See [official guidance](https://developer.android.com/topic/libraries/architecture/paging/v3-compose).
+
+## Remote Keys
+
+```kotlin
+@Entity(tableName = "remote_keys")
+data class RemoteKey(
+    @PrimaryKey val id: String,
+    val nextPage: Int?,
+    val lastUpdated: Long = System.currentTimeMillis(),
+)
+
+@Dao
+interface RemoteKeyDao {
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun insert(key: RemoteKey)
+
+    @Query("SELECT * FROM remote_keys WHERE id = :id")
+    suspend fun getRemoteKey(id: String): RemoteKey?
+
+    @Query("SELECT lastUpdated FROM remote_keys WHERE id = :id")
+    suspend fun getLastUpdated(id: String): Long?
+
+    @Query("DELETE FROM remote_keys WHERE id = :id")
+    suspend fun delete(id: String)
+}
+```

+ 219 - 0
.claude/skills/compose-skill/references/paging.md

@@ -0,0 +1,219 @@
+# Paging 3
+
+Paging 3 setup, PagingSource, transformations, and LazyColumn integration.
+
+References:
+- [Paging 3 with Compose](https://developer.android.com/topic/libraries/architecture/paging/v3-compose)
+- [Load and display paged data](https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data)
+- [LoadState management](https://developer.android.com/topic/libraries/architecture/paging/load-state)
+
+## Critical Performance Rules
+
+1. **PagingData must be a separate Flow, NEVER inside UiState** — wrapping in `data class UiState(val pagingData: PagingData<T>)` causes scroll-to-top on any state change. Use two separate properties: `state: StateFlow<UiState>` + `pagingDataFlow: Flow<PagingData>`. See [anti-patterns](paging-mvi-testing.md#anti-patterns)
+2. **Never create a new Pager per recomposition** — store the Flow as a `val` in ViewModel
+3. **Always `cachedIn(viewModelScope)`** — prevents data loss on config change
+4. **Always provide stable keys** — `itemKey { it.id }` prevents scroll jumps
+5. **Use `flatMapLatest` for parameter changes** — not `combine` on PagingData flows
+
+## Dependencies
+
+```kotlin
+// Android / commonMain
+implementation("androidx.paging:paging-compose:3.3.6")
+implementation("androidx.paging:paging-common:3.3.6")
+testImplementation("androidx.paging:paging-testing:3.3.6")
+```
+
+KMP support (since 3.3.0-alpha02): `paging-common` and `paging-compose` work in `commonMain` (Android, JVM, iOS). `paging-runtime` is Android-only (RecyclerView adapters, not needed in Compose). Verify Web/WASM support for your version.
+
+## Core Data Flow
+
+```text
+PagingSource -> Pager(config, factory) -> Flow<PagingData<T>>
+  -> .cachedIn(viewModelScope) -> collectAsLazyPagingItems() -> LazyColumn/Grid/Pager
+```
+
+| Component | Role |
+|---|---|
+| `PagingSource<Key, Value>` | Loads pages from a single source |
+| `RemoteMediator` | Coordinates network + local DB ([paging-offline.md](paging-offline.md)) |
+| `Pager` | Creates `Flow<PagingData>` from config + source |
+| `PagingConfig` | Page size, prefetch, placeholders |
+| `LazyPagingItems<T>` | Compose wrapper for consuming PagingData |
+
+## PagingSource Implementation
+
+```kotlin
+class ItemPagingSource(
+    private val api: ItemApi,
+    private val query: String,
+) : PagingSource<Int, ItemDto>() {
+
+    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ItemDto> {
+        val page = params.key ?: 1
+        return try {
+            val response = api.getItems(page = page, limit = params.loadSize, query = query)
+            LoadResult.Page(
+                data = response.items,
+                prevKey = if (page == 1) null else page - 1,
+                nextKey = if (response.items.isEmpty()) null else page + 1,
+            )
+        } catch (e: IOException) { LoadResult.Error(e) }
+        catch (e: HttpException) { LoadResult.Error(e) }
+    }
+
+    override fun getRefreshKey(state: PagingState<Int, ItemDto>): Int? =
+        state.anchorPosition?.let { pos ->
+            state.closestPageToPosition(pos)?.let { it.prevKey?.plus(1) ?: it.nextKey?.minus(1) }
+        }
+}
+```
+
+**Rules:** factory must return a **new instance** every call. Catch specific exceptions. Return `null` for `prevKey`/`nextKey` to signal end. For cursor-based APIs, use `String` key type with `nextCursor`.
+
+## Pager and ViewModel Setup
+
+```kotlin
+class ItemListViewModel(private val repository: ItemRepository) : ViewModel() {
+    private val _uiState = MutableStateFlow(ItemListState())
+    val uiState: StateFlow<ItemListState> = _uiState.asStateFlow()
+
+    // PagingData as SEPARATE Flow — never put inside UiState
+    val items: Flow<PagingData<ItemUi>> = Pager(
+        config = PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false, initialLoadSize = 40),
+        pagingSourceFactory = { repository.itemPagingSource() },
+    ).flow
+        .map { pagingData -> pagingData.map { it.toUi() } }
+        .cachedIn(viewModelScope)
+}
+
+data class ItemListState(val selectedFilter: FilterType = FilterType.ALL, val selectedIds: Set<String> = emptySet())
+```
+
+| PagingConfig param | Purpose |
+|---|---|
+| `pageSize` | Items per page (required) |
+| `prefetchDistance` | Distance from edge to trigger next load |
+| `enablePlaceholders` | Show null placeholders for unloaded items |
+| `initialLoadSize` | Items on first request |
+
+## PagingSource Invalidation
+
+Call `PagingSource.invalidate()` after mutations. The factory returns a new instance; Paging reloads from `getRefreshKey`.
+
+```kotlin
+class ItemRepository(private val api: ItemApi) {
+    private var currentPagingSource: ItemPagingSource? = null
+
+    fun itemPagingSource(query: String = ""): PagingSource<Int, ItemDto> =
+        ItemPagingSource(api, query).also { currentPagingSource = it }
+
+    fun invalidate() { currentPagingSource?.invalidate() }
+}
+```
+
+## Filter and Search with Dynamic Parameters
+
+Use `flatMapLatest` to create a new Pager when parameters change. Combine multiple filter flows, then `flatMapLatest`:
+
+```kotlin
+class ItemListViewModel(private val repository: ItemRepository) : ViewModel() {
+    private val _query = MutableStateFlow("")
+    private val _statusFilter = MutableStateFlow(StatusFilter.ALL)
+
+    fun onQueryChanged(query: String) { _query.value = query }
+    fun onStatusChanged(status: StatusFilter) { _statusFilter.value = status }
+
+    val items: Flow<PagingData<ItemUi>> = combine(
+        _query.debounce(300).distinctUntilChanged(),
+        _statusFilter.distinctUntilChanged(),
+    ) { query, status -> query to status }
+        .flatMapLatest { (query, status) ->
+            Pager(
+                config = PagingConfig(pageSize = 20),
+                pagingSourceFactory = { repository.itemPagingSource(query = query, status = status) },
+            ).flow.map { pagingData -> pagingData.map { it.toUi() } }
+        }
+        .cachedIn(viewModelScope)
+}
+```
+
+**Rules:** `distinctUntilChanged()` before `flatMapLatest` avoids redundant Pager creation. `debounce` on text prevents excessive calls. `cachedIn` must come **after** `flatMapLatest`, not inside it. For single-filter, omit `combine` and use the single flow directly.
+
+## Compose UI with LazyPagingItems
+
+```kotlin
+@Composable
+fun ItemListScreen(uiState: ItemListState, pagingItems: LazyPagingItems<ItemUi>, onEvent: (ItemListEvent) -> Unit) {
+    LazyColumn {
+        items(
+            count = pagingItems.itemCount,
+            key = pagingItems.itemKey { it.id },
+            contentType = pagingItems.itemContentType { "item" },
+        ) { index ->
+            pagingItems[index]?.let { item ->
+                ItemRow(item = item, isSelected = uiState.selectedIds.contains(item.id),
+                    onClick = { onEvent(ItemListEvent.ItemClicked(item.id)) })
+            }
+        }
+    }
+}
+```
+
+| Operation | What it does |
+|---|---|
+| `pagingItems[index]` | Access item **and** trigger load |
+| `pagingItems.peek(index)` | Access **without** triggering load |
+| `pagingItems.retry()` | Retry last failed load |
+| `pagingItems.refresh()` | Reload all data (never call from composable body) |
+| `pagingItems.itemKey { }` | Stable keys |
+| `pagingItems.itemContentType { }` | Content type for layout reuse |
+
+Works with **all** lazy layouts (`LazyColumn`, `LazyVerticalGrid`, `HorizontalPager`). Prefer `items` with `itemKey`/`itemContentType` over `itemsIndexed` — indices shift during prepend.
+
+## LoadState Handling
+
+| State | refresh | append/prepend |
+|---|---|---|
+| `Loading` | Initial load or pull-to-refresh | Loading next/previous page |
+| `Error(throwable)` | Initial load failed | Page load failed |
+| `NotLoading(endReached)` | Idle | No more pages / idle |
+
+**Pattern:** branch on `pagingItems.loadState.refresh` — full-screen loading/error/empty only when `itemCount == 0`; with items, use top `LinearProgressIndicator` for refresh and append-row loading/error + `retry()`.
+
+**RemoteMediator note:** check `loadState.source.refresh` instead of `loadState.refresh` — the convenience property may report complete before Room finishes writing.
+
+## PagingData Transformations
+
+Apply on the outer `Flow` **before** `cachedIn`. Transformations after `cachedIn` are lost on cache hit.
+
+```kotlin
+val items: Flow<PagingData<ListItem>> = Pager(config, pagingSourceFactory)
+    .flow
+    .map { pagingData ->
+        pagingData
+            .map { dto -> ListItem.ContentItem(dto.toUi()) }
+            .filter { it.item.status != ItemStatus.DELETED }
+            .insertSeparators { before, after ->
+                when {
+                    before == null -> ListItem.DateHeader("Today")
+                    after == null -> null
+                    before.dateGroup != after.dateGroup -> ListItem.DateHeader(after.dateGroup)
+                    else -> null
+                }
+            }
+    }
+    .cachedIn(viewModelScope)
+
+sealed interface ListItem {
+    data class ContentItem(val item: ItemUi) : ListItem
+    data class DateHeader(val label: String) : ListItem
+}
+```
+
+When using `insertSeparators`, provide unique keys per type (`"item_${id}"`, `"header_${label}"`) and distinct `contentType` values.
+
+## Related References
+
+- **Offline-first paging with Room and RemoteMediator** → [paging-offline.md](paging-offline.md)
+- **MVI dual-flow pattern, testing, and anti-patterns** → [paging-mvi-testing.md](paging-mvi-testing.md)

+ 166 - 0
.claude/skills/compose-skill/references/performance.md

@@ -0,0 +1,166 @@
+# Performance & Recomposition
+
+## Three Phases and Primitive Specializations
+
+Compose executes Composition, Layout, and Drawing phases per frame. State reads in later phases skip earlier phases — moving reads from Composition to Layout/Drawing eliminates recomposition for those reads. Use `Modifier.offset { }` (lambda) instead of `Modifier.offset()`. Use `mutableIntStateOf()`/`mutableFloatStateOf()` instead of `mutableStateOf<Int>()` to avoid boxing. See [compose-essentials.md](compose-essentials.md) for full explanation and code examples.
+
+## Performance Mistakes and Fixes
+
+| # | Issue | Fix |
+|---|---|---|
+| 1 | Unstable parameters (`MutableList`, lambdas in state models, anonymous objects) | Immutable data classes + immutable collections |
+| 2 | Broad state observation — parent reads whole state, ripples through tree | Collect once at route, slice aggressively for leaves |
+| 3 | Large state passed everywhere — many nodes observe unused fields | Pass only what each child renders |
+| 4 | Callback recreation in hot paths (large lazy lists, nested rows) | `remember(key, callback)` for repeated rows |
+| 5 | Expensive calculations during composition (parse, sort, filter, format) | Move upstream to ViewModel/domain |
+| 6 | `remember` misuse — caching business state, hiding architecture issues | Use only for local UI state, expensive local objects, hot callback adaptation |
+| 7 | `derivedStateOf` misuse — wrapping cheap expressions | Use only when derived from rapidly changing Compose state with coarse output |
+| 8 | `rememberSaveable` misuse — entire screen state, large graphs | Use only for tiny UI-local values surviving recreation |
+| 9 | State reads too high in tree (`LazyListState`, animation, keyboard state) | Read close to use |
+| 10 | List recomposition — missing keys, unstable items, inline filters/sorts | Stable keys, immutable models, pre-computed data |
+| 11 | Reducer emits excessive updates — same state, rebuilds on every keystroke | Guard identical transitions, emit only on semantic change |
+| 12 | Ephemeral visual state in global screen state (shimmer alpha, pulse phase) | Keep visual-only state local |
+| 13 | Equality pitfalls — lambdas in data classes, random IDs, mutable collections | No lambdas/mutables in data classes, stable IDs |
+| 14 | Abusing `@Immutable`/`@Stable` to silence compiler | Use only to describe truth — `@Immutable` for truly immutable, `@Stable` rare in app code |
+| 15 | Raw text input in MVI causing stutter (25+ fields) | `TextFieldState`/`BasicTextField2`, group fields into nested data classes, isolate read scopes |
+| 16 | State reads in Composition phase for layout/draw values | Lambda modifiers: `Modifier.offset { IntOffset(scrollOffset, 0) }` |
+
+## API Decision Table
+
+| API | Use it for | Do not use it for |
+|---|---|---|
+| `remember` | local objects/state across recompositions | business state, repo results, derived domain data |
+| `rememberSaveable` | small UI-local state needing restoration | whole screen state, large graphs, domain objects |
+| `derivedStateOf` | reducing downstream updates from fast-changing Compose state | cheap string concatenation, reducer-owned derivations |
+| `key` | preserving identity in dynamic children/lists | hiding bad state models |
+| `LaunchedEffect` | collecting UI effects, startup event, one-shot route work | screen business logic in leaves |
+| `DisposableEffect` | register/unregister listeners with cleanup | long-running business jobs |
+| `produceState` | bridging external async/callback source to local Compose state | replacing a real ViewModel |
+| `snapshotFlow` | turning Compose state reads into `Flow` operators | normal state rendering |
+| `collectAsState` | collect `StateFlow` into Compose | collecting everywhere in the tree |
+| lifecycle-aware collection | Lifecycle host integration (multiplatform since lifecycle 2.8+) | common leaf components |
+| stable callbacks | hot repeated UI paths | every single callback everywhere |
+
+## Code Examples
+
+### BAD: calculating derived results in a composable
+
+```kotlin
+@Composable
+fun CalculatorResult(state: CalculatorState) {
+    val area = state.input.areaText.toDoubleOrNull() ?: 0.0
+    val materialRate = state.input.materialRateText.toDoubleOrNull() ?: 0.0
+    val subtotal = (area * materialRate)
+    Text("Subtotal: $subtotal")
+}
+```
+
+### GOOD: derive upstream, narrow reads
+
+```kotlin
+@Composable
+fun CalculatorScreen(state: CalculatorState, onEvent: (CalculatorEvent) -> Unit) {
+    Header(title = "Estimator")
+    CalculatorForm(
+        input = state.input,
+        validation = state.validation,
+        enabled = !state.isRefreshingQuote,
+        onAreaChanged = { onEvent(CalculatorEvent.FieldChanged(FormField.Area, it)) },
+    )
+    ResultCard(derived = state.derived, isRefreshing = state.isRefreshingQuote)
+}
+
+@Composable
+fun CalculatorResult(derived: CalculatorDerived?) {
+    Text(text = derived?.subtotal?.toString() ?: "—")
+}
+```
+
+### BAD: unstable list items
+
+```kotlin
+data class HistoryRowState(
+    val id: String, val title: String,
+    val tags: MutableList<String>,   // unstable
+    val onClick: () -> Unit,         // lambda in data class
+)
+```
+
+### GOOD: immutable models, stable keys, callback stability
+
+```kotlin
+@Immutable
+data class HistoryRowUi(val id: String, val title: String, val subtitle: String)
+
+@Composable
+fun HistoryList(items: ImmutableList<HistoryRowUi>, onOpen: (String) -> Unit) {
+    LazyColumn {
+        items(items = items, key = { it.id }) { item ->
+            val onClick = remember(item.id, onOpen) { { onOpen(item.id) } }
+            ListItem(
+                headlineContent = { Text(item.title) },
+                supportingContent = { Text(item.subtitle) },
+                modifier = Modifier.clickable(onClick = onClick),
+            )
+        }
+    }
+}
+```
+
+### GOOD: correct `derivedStateOf`
+
+```kotlin
+val listState = rememberLazyListState()
+val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 2 } }
+```
+
+### BAD: unnecessary `derivedStateOf`
+
+```kotlin
+val text by remember { derivedStateOf { if (canSubmit) "Submit" else "Fix errors" } }
+// Just write: Text(if (canSubmit) "Submit" else "Fix errors")
+```
+
+### GOOD: guard identical transitions
+
+```kotlin
+private fun onAreaEdited(raw: String) {
+    val old = _state.value
+    if (old.input.areaText == raw) return
+    _state.value = old.copy(input = old.input.copy(areaText = raw))
+}
+```
+
+## Compiler and Build Optimizations
+
+- **Strong Skipping Mode** — enable via compiler flags; allows composables with unstable parameters to skip based on instance equality (`===`)
+- **Stability config** — use `stability_config.conf` to mark external classes as stable: `com.example.network.dto.*`, `kotlinx.datetime.Instant`
+- **Compose Compiler Metrics** — audit `restartable`/`skippable` characteristics regularly
+
+## Baseline Profiles (Android)
+
+Pre-compile hot code paths via Jetpack Macrobenchmark to reduce startup time and jank:
+
+```kotlin
+@RunWith(AndroidBenchmarkRunner::class)
+class StartupBenchmark {
+    @get:Rule val benchmarkRule = MacrobenchmarkRule()
+
+    @Test
+    fun startup() = benchmarkRule.measureRepeated(
+        packageName = "com.example.app",
+        metrics = listOf(StartupTimingMetric()),
+        iterations = 10,
+        setupBlock = { pressHome(); startActivityAndWait() }
+    ) { /* interact with app */ }
+}
+```
+
+Target <16.67ms per frame for 60fps. Use `FrameTimingMetric()` for scroll/interaction benchmarks.
+
+### R8/ProGuard Rules for Compose (Android only)
+
+```proguard
+-keep @androidx.compose.runtime.Stable class **
+-keep @androidx.compose.runtime.Immutable class **
+```

+ 206 - 0
.claude/skills/compose-skill/references/resources.md

@@ -0,0 +1,206 @@
+# Compose Multiplatform Resources
+
+## Android R vs CMP Res
+
+Android uses `R` — a generated class with integer IDs. Compose Multiplatform uses `Res` — a generated class with typed accessors. The API surface is intentionally similar, but the types and import paths differ.
+
+| Concern | Android (Jetpack Compose) | Compose Multiplatform |
+|---|---|---|
+| Generated class | `R` (integer resource IDs) | `Res` (typed resource objects) |
+| String access | `stringResource(R.string.app_name)` | `stringResource(Res.string.app_name)` |
+| Drawable access | `painterResource(R.drawable.icon)` | `painterResource(Res.drawable.icon)` |
+| Plural access | `pluralStringResource(R.plurals.items, count)` | `pluralStringResource(Res.plurals.items, count)` |
+| Font access | `FontFamily(Font(R.font.inter))` | `FontFamily(Font(Res.font.inter))` |
+| String array | `stringArrayResource(R.array.items)` | `stringArrayResource(Res.array.items)` |
+| Resource directory | `res/` (under each source set) | `composeResources/` (under each source set) |
+| Import path | `import com.example.app.R` | `import project.module.generated.resources.Res` |
+| Suspend access | N/A | `getString(Res.string.app_name)` |
+| Raw file access | `context.assets.open("file.bin")` | `Res.readBytes("files/file.bin")` |
+| Platform URI | `ContentResolver` / asset URI | `Res.getUri("files/video.mp4")` |
+
+**Import convention:** `{group}.{module}.generated.resources.Res`. Individual accessors imported separately:
+
+```kotlin
+import project.composeapp.generated.resources.Res
+import project.composeapp.generated.resources.app_name
+import project.composeapp.generated.resources.my_image
+```
+
+## Directory Structure
+
+Place resources under `composeResources/` in the owning source set. `commonMain` for shared, platform source sets for platform-specific.
+
+```text
+commonMain/composeResources/
+├── drawable/              PNG, JPG, BMP, WebP, Android XML vectors, SVG (all except Android)
+│   ├── drawable-dark/     dark theme variants
+│   └── drawable-xxhdpi/   density-specific variants
+├── font/                  TTF, OTF
+├── values/                strings.xml (strings, string-arrays, plurals) — base locale
+│   ├── values-es/         Spanish
+│   ├── values-fr/         French
+│   └── values-ja/         Japanese
+└── files/                 raw files, any sub-hierarchy
+    └── myDir/data.json
+```
+
+Qualifiers use hyphens and can combine: `drawable-en-rUS-mdpi-dark`. Fallback: unqualified resource.
+
+## Gradle Setup
+
+```kotlin
+kotlin {
+    sourceSets {
+        commonMain.dependencies {
+            implementation(compose.components.resources)
+        }
+    }
+}
+
+compose.resources {
+    publicResClass = true              // default: internal; needed for library modules
+    packageOfResClass = "com.example.app.resources"  // default: {group}.{module}.generated.resources
+    generateResClass = auto            // auto | always
+}
+```
+
+For `androidLibrary` targets (AGP 8.8.0+), enable explicitly: `kotlin { androidLibrary { androidResources.enable = true } }`.
+
+Build the project to generate/regenerate the `Res` class and typed accessors.
+
+## Drawables and Images
+
+Store in `composeResources/drawable/`. Use `painterResource` as the primary API — returns `Painter` for both raster and vector. Works synchronously except web (empty on first composition, then loads).
+
+```kotlin
+Image(painter = painterResource(Res.drawable.my_image), contentDescription = null)
+val bitmap: ImageBitmap = imageResource(Res.drawable.photo)      // raster only
+val vector: ImageVector = vectorResource(Res.drawable.ic_arrow)  // XML vector only
+```
+
+## Icons
+
+Use Material Symbols XML icons from [Google Fonts Icons](https://fonts.google.com/icons). Download the Android XML variant, place in `composeResources/drawable/`, set `android:fillColor` to `#000000`, remove `android:tint`.
+
+```kotlin
+Image(
+    painter = painterResource(Res.drawable.ic_settings),
+    contentDescription = "Settings",
+    modifier = Modifier.size(24.dp),
+    colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
+)
+```
+
+## Strings, Templates, Arrays, and Plurals
+
+Store in `composeResources/values/strings.xml`. Each element generates a typed accessor on `Res`.
+
+| Type | XML | Composable API | Suspend API |
+|---|---|---|---|
+| String | `<string name="k">text</string>` | `stringResource(Res.string.k)` | `getString(Res.string.k)` |
+| Template | `<string name="k">Hello, %1$s!</string>` | `stringResource(Res.string.k, name)` | `getString(Res.string.k, name)` |
+| String array | `<string-array name="k"><item>A</item></string-array>` | `stringArrayResource(Res.array.k)` | `getStringArray(Res.array.k)` |
+| Plurals | `<plurals name="k"><item quantity="one">%1$d item</item><item quantity="other">%1$d items</item></plurals>` | `pluralStringResource(Res.plurals.k, count, count)` | `getPluralString(Res.plurals.k, count, count)` |
+
+Canonical example:
+
+```xml
+<resources>
+    <string name="app_name">My App</string>
+    <string name="welcome">Hello, %1$s! You have %2$d new messages.</string>
+    <string-array name="categories">
+        <item>Electronics</item>
+        <item>Clothing</item>
+    </string-array>
+    <plurals name="items_count">
+        <item quantity="one">%1$d item</item>
+        <item quantity="other">%1$d items</item>
+    </plurals>
+</resources>
+```
+
+Special characters: `\n`, `\t`, `\uXXXX`. Unlike Android, no need to escape `@` or `?`. For plurals, the first `count` selects the form; additional args are format arguments. No functional difference between `$s` and `$d`. Supported quantities: `zero`, `one`, `two`, `few`, `many`, `other` — not all apply to every language.
+
+## Fonts
+
+Store `.ttf`/`.otf` in `composeResources/font/`. `Font()` is a **composable** in CMP (unlike Android), so dependent `TextStyle`/`Typography` construction must also be composable:
+
+```kotlin
+@Composable
+fun AppTypography(): Typography {
+    val fontFamily = FontFamily(
+        Font(Res.font.Inter_Regular, FontWeight.Normal),
+        Font(Res.font.Inter_Bold, FontWeight.Bold),
+    )
+    return MaterialTheme.typography.copy(
+        bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = fontFamily),
+        titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = fontFamily, fontWeight = FontWeight.Bold),
+    )
+}
+```
+
+## Raw Files and URIs
+
+Place arbitrary files in `composeResources/files/` with any sub-hierarchy.
+
+```kotlin
+// Read bytes (suspend)
+val bytes = Res.readBytes("files/data.json")
+
+// Convert to images
+val bitmap: ImageBitmap = bytes.decodeToImageBitmap()
+val vector: ImageVector = bytes.decodeToImageVector(LocalDensity.current)
+val painter: Painter = bytes.decodeToSvgPainter(LocalDensity.current)  // all platforms except Android
+
+// Get platform URI for external APIs (WebView, media players)
+val uri: String = Res.getUri("files/intro.mp4")
+```
+
+Since CMP 1.7.0, multiplatform resources are packed into Android assets — enabling `@Preview` and `WebView`/media access via URI.
+
+## Qualifiers Reference
+
+| Qualifier | Format | Example |
+|---|---|---|
+| Language / region | ISO 639-1/2; optional `r` + ISO 3166-1-alpha-2 | `values-es/`, `values-fra/`, `values-es-rMX/` |
+| Theme | `light` or `dark` | `drawable-dark/` |
+| Density | `ldpi`/`mdpi`/`hdpi`/`xhdpi`/`xxhdpi`/`xxxhdpi` | `drawable-xxhdpi/` |
+
+`stringResource()` automatically selects the correct locale at runtime — no code changes needed.
+
+## Remote Images
+
+For loading images from URLs, use a dedicated library — multiplatform resources are for bundled assets only. See [image-loading.md](image-loading.md).
+
+## MVI Integration
+
+**Rule: semantic keys in state, resource resolution in UI.** ViewModels use enums/semantic values — never resolved strings or resource IDs. UI maps semantic keys to `stringResource()`/`painterResource()` at render time.
+
+```kotlin
+enum class ErrorKey { NetworkError, InvalidInput, Unauthorized }
+data class ProfileState(val userName: String = "", val error: ErrorKey? = null)
+
+state.error?.let { key ->
+    Text(stringResource(when (key) {
+        ErrorKey.NetworkError -> Res.string.error_network
+        ErrorKey.InvalidInput -> Res.string.error_invalid_input
+        ErrorKey.Unauthorized -> Res.string.error_unauthorized
+    }))
+}
+```
+
+For full MVI ViewModel collection pattern, see [architecture.md](architecture.md).
+
+## Rules
+
+- Use `composeResources/` for all shared strings, images, fonts, and raw files
+- Use typed accessors (`Res.string.name`) for compile-time safety
+- Use qualifiers for localization (`values-es/`), theme (`drawable-dark/`), density (`drawable-xxhdpi/`)
+- Keep resource resolution in composables — call `stringResource()`/`painterResource()` at render time
+- Use suspend variants (`getString()`, `getPluralString()`) for non-composable contexts
+- Set `publicResClass = true` when sharing resources from a library module
+- Use semantic keys/enums in state; map to resources in UI
+- Never resolve strings or load resources inside reducers or ViewModels
+- Never use Android `R.string`/`R.drawable` in `commonMain` — use `Res`
+- Never place platform-only assets (Android adaptive icons, iOS asset catalogs) in `composeResources/`
+- Rebuild after adding new resources — the `Res` class needs regeneration

+ 256 - 0
.claude/skills/compose-skill/references/room-database.md

@@ -0,0 +1,256 @@
+# Room Database
+
+SQLite persistence via Room (KMP-ready since 2.7.0) for Compose Multiplatform and Android projects.
+
+References:
+- [Save data in a local database using Room](https://developer.android.com/training/data-storage/room)
+- [Set up Room Database for KMP](https://developer.android.com/kotlin/multiplatform/room)
+- [SQLite performance best practices](https://developer.android.com/topic/performance/sqlite-performance-best-practices)
+
+## Setup
+
+> **Always search online for the latest stable versions** of `androidx.room`, `androidx.sqlite`, and `com.google.devtools.ksp` before adding dependencies.
+
+### Dependencies (version catalog)
+
+```toml
+[versions]
+room = "<latest>"        # search: "androidx.room latest version"
+sqlite = "<latest>"      # search: "androidx.sqlite latest version"
+ksp = "<latest>"         # must match your Kotlin version
+
+[libraries]
+androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
+androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
+androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
+
+[plugins]
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+androidx-room = { id = "androidx.room", version.ref = "room" }
+```
+
+### KMP Gradle
+
+```kotlin
+plugins {
+    alias(libs.plugins.ksp)
+    alias(libs.plugins.androidx.room)
+}
+kotlin {
+    sourceSets.commonMain.dependencies {
+        implementation(libs.androidx.room.runtime)
+        implementation(libs.androidx.sqlite.bundled)
+    }
+}
+dependencies {
+    add("kspAndroid", libs.androidx.room.compiler)
+    add("kspIosArm64", libs.androidx.room.compiler)
+    // ... add for every target
+}
+room { schemaDirectory("$projectDir/schemas") }
+```
+
+**Android-only:** use `ksp(libs.androidx.room.compiler)` directly.
+
+### Database definition
+
+```kotlin
+@Database(entities = [ProjectEntity::class, TaskEntity::class], version = 1)
+@ConstructedBy(AppDatabaseConstructor::class)
+abstract class AppDatabase : RoomDatabase() {
+    abstract fun projectDao(): ProjectDao
+    abstract fun taskDao(): TaskDao
+}
+
+@Suppress("KotlinNoActualForExpect")
+expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
+    override fun initialize(): AppDatabase
+}
+```
+
+Room generates `actual` implementations per platform. **Android-only:** skip `@ConstructedBy`, use `Room.databaseBuilder(context, AppDatabase::class.java, "app.db")`.
+
+### Database instantiation
+
+```kotlin
+fun getRoomDatabase(builder: RoomDatabase.Builder<AppDatabase>): AppDatabase =
+    builder.setDriver(BundledSQLiteDriver()).setQueryCoroutineContext(Dispatchers.IO).build()
+```
+
+Each platform provides its own `getDatabaseBuilder`. See [KMP setup guide](https://developer.android.com/kotlin/multiplatform/room).
+
+## Critical Performance Rules
+
+| Rule | Why |
+|------|-----|
+| Index every column in `WHERE`, `ORDER BY`, `JOIN ON` | Avoids full table scan: O(n) → O(log n) |
+| Batch writes inside `@Transaction` | Individual inserts each trigger separate disk sync |
+| Select only needed columns (projection data classes) | Reduces memory and I/O vs `SELECT *` |
+| `Flow` for reactive reads, `suspend` for writes | Auto-notify on changes; keep main thread free |
+| Never `allowMainThreadQueries()` in production | Blocks UI, causes ANRs |
+| Use `BundledSQLiteDriver` for KMP | Consistent SQLite version across platforms |
+| Provide `RoomDatabase` as DI singleton | Each instance manages its own connection pool |
+
+## Entity Design
+
+```kotlin
+@Entity(
+    tableName = "tasks",
+    indices = [Index("projectId"), Index("projectId", "dueDate")]
+)
+data class TaskEntity(
+    @PrimaryKey(autoGenerate = true) val id: Long = 0,
+    val title: String,
+    val description: String,
+    val projectId: Long,
+    @ColumnInfo(name = "due_date") val dueDate: Long? = null,
+    @ColumnInfo(defaultValue = "0") val isCompleted: Boolean = false,
+    @Ignore val displayOrder: Int = 0
+)
+```
+
+Composite key: `@Entity(primaryKeys = ["taskId", "labelId"])`. Full-text search: `@Fts4(contentEntity = ...)` with `MATCH` queries.
+
+### Indexes
+
+| Scenario | Index? | Reason |
+|----------|--------|--------|
+| Column in `WHERE`/`ORDER BY`/`JOIN ON` | Yes | Avoids full scan / sort pass |
+| Foreign key column | Yes | Room warns if missing |
+| Rarely queried column / tiny table | No | Wastes storage, slows writes |
+
+Composite index `(a, b)` accelerates queries on `a` alone or both. Column order matters — most selective first.
+
+## DAO Patterns
+
+```kotlin
+@Dao
+interface TaskDao {
+    @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insert(task: TaskEntity): Long
+    @Insert suspend fun insertAll(tasks: List<TaskEntity>): List<Long>
+    @Update suspend fun update(task: TaskEntity)
+    @Upsert suspend fun upsert(task: TaskEntity)
+    @Delete suspend fun delete(task: TaskEntity)
+    @Query("DELETE FROM tasks WHERE projectId = :projectId") suspend fun deleteByProject(projectId: Long)
+
+    @Query("SELECT * FROM tasks WHERE projectId = :projectId ORDER BY due_date ASC")
+    fun observeByProject(projectId: Long): Flow<List<TaskEntity>>
+
+    @Query("SELECT * FROM tasks WHERE id = :id") suspend fun getById(id: Long): TaskEntity?
+}
+```
+
+`@Upsert` (Room 2.5+) inserts or updates by primary key. Prefer over `@Insert(onConflict = REPLACE)` which deletes then re-inserts, triggering cascading deletes. Room auto-invalidates `Flow` queries on table changes.
+
+**KMP:** all DAO functions for non-Android must be `suspend` or return `Flow`.
+
+### Performance-oriented queries
+
+```kotlin
+data class TaskSummary(val id: Long, val title: String, @ColumnInfo(name = "due_date") val dueDate: Long?)
+
+@Query("SELECT id, title, due_date FROM tasks WHERE projectId = :projectId")
+fun observeSummaries(projectId: Long): Flow<List<TaskSummary>>
+
+@Query("SELECT projectId, COUNT(*) AS taskCount, SUM(CASE WHEN isCompleted = 1 THEN 1 ELSE 0 END) AS completedCount FROM tasks GROUP BY projectId")
+suspend fun getProjectStats(): List<ProjectStats>
+
+@Transaction
+suspend fun replaceAllForProject(projectId: Long, tasks: List<TaskEntity>) {
+    deleteByProject(projectId); insertAll(tasks)
+}
+```
+
+Always use `:paramName` bind parameters — never concatenate. Use `LIMIT` for bounded results. For unbounded scrolling, use [Paging](paging.md). For offline-first paging with Room, see [paging-offline.md](paging-offline.md).
+
+## Relationships
+
+### One-to-many
+
+```kotlin
+data class ProjectWithTasks(
+    @Embedded val project: ProjectEntity,
+    @Relation(parentColumn = "id", entityColumn = "projectId") val tasks: List<TaskEntity>
+)
+
+@Transaction @Query("SELECT * FROM projects WHERE id = :id")
+suspend fun getWithTasks(id: Long): ProjectWithTasks?
+```
+
+Always `@Transaction` on relational queries — Room issues multiple queries internally.
+
+### Many-to-many with Junction
+
+```kotlin
+@Entity(
+    tableName = "task_labels", primaryKeys = ["taskId", "labelId"],
+    foreignKeys = [
+        ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["taskId"], onDelete = ForeignKey.CASCADE),
+        ForeignKey(entity = LabelEntity::class, parentColumns = ["id"], childColumns = ["labelId"], onDelete = ForeignKey.CASCADE)
+    ],
+    indices = [Index("labelId")]
+)
+data class TaskLabelCrossRef(val taskId: Long, val labelId: Long)
+
+data class TaskWithLabels(
+    @Embedded val task: TaskEntity,
+    @Relation(parentColumn = "id", entityColumn = "id",
+        associateBy = Junction(TaskLabelCrossRef::class, parentColumn = "taskId", entityColumn = "labelId"))
+    val labels: List<LabelEntity>
+)
+```
+
+## TypeConverters
+
+```kotlin
+class Converters {
+    @TypeConverter fun fromInstant(value: Long?): Instant? = value?.let { Instant.fromEpochMilliseconds(it) }
+    @TypeConverter fun toInstant(instant: Instant?): Long? = instant?.toEpochMilliseconds()
+}
+```
+
+**KMP:** use `kotlinx-datetime`. Reserve TypeConverters for simple mappings (timestamps, enums) — prefer normalized tables over JSON blobs.
+
+## Transactions
+
+- **KMP:** `database.useWriterConnection { it.immediateTransaction { } }` for writes, `database.useReaderConnection { it.deferredTransaction { } }` for consistent reads
+- **Android-only:** `database.withTransaction { }` (not available in KMP `commonMain`)
+- **DAO-level:** `@Transaction` to group multiple queries atomically
+
+## Migrations
+
+```kotlin
+val MIGRATION_1_2 = object : Migration(1, 2) {
+    override fun migrate(connection: SQLiteConnection) {
+        connection.execSQL("ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0")
+    }
+}
+```
+
+`.addMigrations(MIGRATION_1_2)`. **AutoMigration:** `autoMigrations = [AutoMigration(from = 1, to = 2)]` for simple changes. Export schema to VCS; `fallbackToDestructiveMigration()` only in early dev.
+
+## MVI Integration
+
+Map entities to domain models at the repository boundary (`TaskEntity.toDomain()` / `Task.toEntity()`). Never pass `@Entity` classes to the UI. Provide `RoomDatabase` and DAOs as DI singletons.
+
+For the ViewModel collection pattern, see [architecture.md](architecture.md) — Reactive Data Collection.
+
+## Testing
+
+- **DAO tests:** `Room.inMemoryDatabaseBuilder<AppDatabase>()` with `BundledSQLiteDriver` + test dispatcher. Test `Flow` with Turbine.
+- **Migration tests:** `MigrationTestHelper` — create at old version, run `runMigrationsAndValidate`, verify.
+- **ViewModel tests:** Fake DAO backed by `MutableStateFlow<List<Entity>>`. See [testing.md](testing.md).
+
+## Anti-Patterns
+
+| Anti-pattern | Why it is harmful | Better replacement |
+|---|---|---|
+| `allowMainThreadQueries()` | Blocks UI, ANRs | `suspend` + `Flow` |
+| `SELECT *` everywhere | Loads unused columns | Projection data classes |
+| Missing indexes on queried columns | Full table scan | `@Entity(indices = [...])` |
+| Destructive fallback only | Users lose data | `Migration` or `AutoMigration` |
+| `@Insert(onConflict = REPLACE)` with FKs | Cascading deletes | `@Upsert` |
+| Blocking DAO functions on KMP | Crashes non-Android | `suspend` or `Flow` |
+| No `@Transaction` on relational queries | Inconsistent snapshot | Always `@Transaction` with `@Relation` |
+| Multiple `RoomDatabase` instances | Breaks invalidation | DI singleton |
+| Large blobs / nested JSON via TypeConverter | Bloats DB, opaque to SQL | File paths + normalized tables |

+ 222 - 0
.claude/skills/compose-skill/references/testing.md

@@ -0,0 +1,222 @@
+# Testing Strategy
+
+## What to Test in commonMain
+
+### ViewModel Tests with Turbine (highest ROI)
+
+Test the full event→state→effect cycle through the ViewModel. Use `kotlinx-coroutines-test` with the **Turbine** library:
+
+```kotlin
+@Test
+fun `save with empty title shows validation error`() = runTest {
+    val viewModel = CreateItemViewModel(FakeItemRepository())
+
+    viewModel.state.test {
+        val initial = awaitItem()
+        assertTrue(initial.errors.isEmpty())
+
+        viewModel.onEvent(CreateItemEvent.OnSaveClick)
+        val afterSave = awaitItem()
+
+        assertEquals("Title is required", afterSave.errors["title"])
+        assertFalse(afterSave.isSaving)
+    }
+}
+
+@Test
+fun `save with valid input transitions through saving to success`() = runTest {
+    val viewModel = CreateItemViewModel(FakeItemRepository())
+
+    viewModel.state.test {
+        awaitItem() // initial
+
+        viewModel.onEvent(CreateItemEvent.OnTitleChanged("New item"))
+        awaitItem()
+        viewModel.onEvent(CreateItemEvent.OnAmountChanged("42.5"))
+        awaitItem()
+
+        viewModel.onEvent(CreateItemEvent.OnSaveClick)
+        val saving = awaitItem()
+        assertTrue(saving.isSaving)
+
+        val done = awaitItem()
+        assertFalse(done.isSaving)
+    }
+}
+
+@Test
+fun `title changed clears title validation error`() = runTest {
+    val viewModel = CreateItemViewModel(FakeItemRepository())
+
+    viewModel.state.test {
+        awaitItem() // initial
+
+        viewModel.onEvent(CreateItemEvent.OnSaveClick)
+        val withError = awaitItem()
+        assertTrue(withError.errors.containsKey("title"))
+
+        viewModel.onEvent(CreateItemEvent.OnTitleChanged("A"))
+        val cleared = awaitItem()
+        assertFalse(cleared.errors.containsKey("title"))
+    }
+}
+
+@Test
+fun `save emits ShowMessage effect on success`() = runTest {
+    val viewModel = CreateItemViewModel(FakeItemRepository())
+
+    viewModel.onEvent(CreateItemEvent.OnTitleChanged("New item"))
+    viewModel.onEvent(CreateItemEvent.OnAmountChanged("10"))
+
+    viewModel.effect.test {
+        viewModel.onEvent(CreateItemEvent.OnSaveClick)
+        val effect = awaitItem()
+        assertTrue(effect is CreateItemEffect.ShowMessage)
+    }
+}
+```
+
+**What to test:**
+
+- Event→state transitions: field edits, validation triggers, loading states
+- Event→effect emissions: navigation, snackbar, error messages
+- Async flows: loading → success, loading → failure, retry
+- Edge cases: empty input, duplicate detection, concurrent saves
+- State preservation: old content kept during refresh, error doesn't wipe data
+
+### Testing State and Effects Separately
+
+When a single event produces both state changes and effects, test them independently for clarity:
+
+```kotlin
+@Test
+fun `back click emits NavigateBack effect without changing state`() = runTest {
+    val viewModel = CreateItemViewModel(FakeItemRepository())
+
+    viewModel.effect.test {
+        viewModel.onEvent(CreateItemEvent.OnBackClick)
+        assertEquals(CreateItemEffect.NavigateBack, awaitItem())
+    }
+
+    viewModel.state.test {
+        val state = awaitItem()
+        assertEquals(CreateItemState(), state)
+    }
+}
+```
+
+### Validation Tests
+
+Test validation logic as pure functions when extracted into a dedicated validator:
+
+```kotlin
+@Test
+fun `validator rejects blank title`() {
+    val errors = CreateItemValidator.validate(title = "", amount = "10")
+    assertEquals("Title is required", errors["title"])
+}
+
+@Test
+fun `validator accepts valid input`() {
+    val errors = CreateItemValidator.validate(title = "Widget", amount = "25.0")
+    assertTrue(errors.isEmpty())
+}
+```
+
+If validation is inline in the ViewModel (acceptable for simple cases), test it through ViewModel events as shown above.
+
+### Calculation Engine Tests
+
+Test pure calculation/domain services directly — no ViewModel needed:
+
+```kotlin
+@Test
+fun `calculator computes correct monthly payment`() {
+    val result = LoanCalculator.monthlyPayment(amount = 100000.0, rate = 5.0, years = 30)
+    assertEquals(536.82, result, 0.01)
+}
+```
+
+Test: edge cases, rounding policy, domain invariants, regression fixtures.
+
+### Fake Repositories for ViewModel Tests
+
+Use fakes (not mocks) for repositories and services:
+
+```kotlin
+class FakeItemRepository : ItemRepository {
+    private val items = mutableListOf<Item>()
+    var shouldThrow: Exception? = null
+
+    override suspend fun create(title: String, amount: Double) {
+        shouldThrow?.let { throw it }
+        items.add(Item(title = title, amount = amount))
+    }
+
+    override suspend fun getAll(): List<Item> = items.toList()
+}
+```
+
+Fakes give you control over success/failure scenarios without mock framework complexity.
+
+## Compose UI Tests
+
+Compose Multiplatform common UI testing uses `runComposeUiTest` rather than Android's JUnit `TestRule` model.
+
+Test:
+
+- Critical field entry flows
+- Submit enable/disable behavior
+- Error visibility
+- Loading placeholder/content swap
+- Preserved content during refresh
+- Accessibility labels on critical controls
+
+## Platform Tests
+
+### Android/iOS specific
+
+Test:
+
+- Platform shell wiring
+- Deep-link entry
+- Navigation host integration
+- Share sheet / clipboard / haptic bindings
+- Platform lifecycle edge cases
+- Keyboard/safe-area regressions
+
+## Snapshot Testing Caveats
+
+Per-platform rendering, typography, and layout differ; shared Android/iOS goldens are brittle.
+
+**Default:** prefer semantic assertions and interaction tests; use per-platform visual goldens only for a few high-value screens.
+
+## Lean Default Test Matrix
+
+1. ViewModel event→state→effect tests for every feature (via Turbine)
+2. Validation/calculation tests for every rule-heavy feature (pure function tests)
+3. UI tests for high-risk screens
+4. Platform integration tests only for real platform behavior
+
+Do not sink weeks into screenshot infrastructure before you have ViewModel test coverage.
+
+## Anti-Patterns
+
+| Anti-pattern | Why it hurts | Better replacement |
+|---|---|---|
+| No ViewModel tests, only UI tests | slow feedback, flaky, hard to isolate failures | ViewModel event→state→effect tests with Turbine first |
+| Testing implementation details (private functions, internal state) | brittle tests that break on refactoring | test through public API: send event, assert state/effect |
+| Mocking the DI framework | couples tests to DI internals | swap real implementations with fakes via constructor injection |
+| Screenshot tests before ViewModel coverage | high maintenance, low defect yield | establish ViewModel + validator coverage first, then add screenshots selectively |
+| Testing derived/computed properties in isolation from ViewModel | duplicates logic, drifts from real behavior | test derived values through ViewModel state assertions |
+| Sharing mutable test fixtures across tests | hidden coupling, order-dependent failures | fresh state per test, explicit setup in each test function |
+
+## Domain-Specific Testing
+
+Some reference files contain their own testing sections with domain-specific patterns:
+
+| Domain | Reference | What it covers |
+|---|---|---|
+| Paging 3 | [paging-mvi-testing.md](paging-mvi-testing.md) | PagingSource unit tests, `asSnapshot`, `TestPager` transformations |
+| Room Database | [room-database.md](room-database.md) | In-memory DB tests, migration tests, fake DAOs |
+| Networking | [networking-ktor-testing.md](networking-ktor-testing.md) | MockEngine, API response testing, DI integration |

+ 170 - 0
.claude/skills/compose-skill/references/ui-ux.md

@@ -0,0 +1,170 @@
+# UI/UX Patterns for Utility Apps
+
+## Core Principles
+
+Utility apps are trust products. The UI must feel: stable, immediate, precise, reversible, non-destructive.
+
+## Loading States
+
+### Decision Rule
+
+| Situation | Best default |
+|---|---|
+| First load, known result card layout | skeleton |
+| Small inline refresh of one section | keep content + small inline indicator |
+| Whole-screen blocking startup with no known structure | spinner, but rare |
+| Recalculating quote while old result exists | keep old result + "updating" affordance |
+| Empty but idle state | empty-state hint, not spinner |
+
+### Default Recommendation
+
+- **Skeleton**: default for known layout with missing data
+- **Subtle shimmer over skeleton**: optional polish, not the primary strategy
+- **Spinner**: only for small unknown-layout operations or blocking tasks with no stable placeholder shape
+
+### Stable Layout During Loading
+
+Never wipe content during refresh. Never cause height jumps, flicker, or lost context.
+
+## Inline Validation
+
+Default behavior:
+
+- Validate format/range as user edits for fields where feedback is obvious
+- Avoid screaming errors on untouched fields
+- Show errors inline, next to the field they belong to
+- Do not collapse layout when error appears/disappears
+- Disable submit when impossible, but also explain why
+
+### Good inline validation behavior
+
+- Field keeps its value during error
+- Error appears under field
+- Submit remains disabled only when necessary
+- No modal dialog for every invalid keystroke
+- No full-form red error wall
+
+## Disabled States
+
+Disabled is fine only when:
+
+- The reason is obvious from nearby context
+- The screen is still readable
+- User input is preserved
+
+Bad disabled state: button disabled with no visible reason, form cleared during loading, entire screen grayed out for a small refresh.
+
+## Preserving User Input
+
+Non-negotiable rules:
+
+- **Never clear edited fields on refresh**
+- **Never clear last good result while fetching a new one**
+- **Never wipe the screen because one request failed**
+
+## Progressive Disclosure
+
+For dense forms:
+
+- Hide advanced options by default
+- Keep main path obvious
+- Reveal secondary controls progressively
+- Do not split trivial forms into too many steps
+
+## Partial Results
+
+Good pattern:
+
+- Compute instant local estimate from current draft
+- Show local estimate immediately
+- Fetch remote refinement in background
+- Keep old refined quote until new one arrives
+- Label refreshed state clearly
+
+## Perceived Performance
+
+For form-heavy screens:
+
+- Apply local field state changes instantly
+- Recalculate cheap deterministic outputs immediately
+- Debounce only expensive async work
+- Keep layout stable
+- Animate only meaningful content changes
+
+## Accessibility
+
+- Error messages must be text, not color only
+- Loading indicators should not hide context unnecessarily
+- Support logical keyboard/focus order
+- Avoid rapid flashing/sweeping shimmer
+- Keep controls large enough for data-entry reliability
+
+## Code Examples
+
+### BAD: disappearing content and layout jumps
+
+```kotlin
+@Composable
+fun QuoteSection(quote: QuoteUi?, isLoading: Boolean) {
+    if (isLoading) {
+        CircularProgressIndicator()
+    } else if (quote != null) {
+        QuoteContent(quote = quote, refreshing = false)
+    }
+}
+```
+
+### GOOD: stable layout with old content preserved
+
+```kotlin
+@Composable
+fun QuoteSection(quote: QuoteUi?, isLoading: Boolean) {
+    ResultCardSlot {
+        when {
+            quote != null -> QuoteContent(quote = quote, refreshing = isLoading)
+            isLoading -> QuoteCardSkeleton()
+            else -> QuoteEmptyState()
+        }
+    }
+}
+```
+
+### GOOD: stable placeholder slot
+
+```kotlin
+@Composable
+fun ResultCardSlot(content: @Composable BoxScope.() -> Unit) {
+    Box(modifier = Modifier.fillMaxWidth().heightIn(min = 180.dp)) { content() }
+}
+```
+
+### GOOD: skeleton with shimmer
+
+```kotlin
+@Composable
+fun QuoteCardSkeleton(modifier: Modifier = Modifier) {
+    val alpha by rememberInfiniteTransition(label = "skeleton").animateFloat(
+        initialValue = 0.35f,
+        targetValue = 0.60f,
+        animationSpec = infiniteRepeatable(
+            animation = tween(durationMillis = 800),
+            repeatMode = RepeatMode.Reverse,
+        ),
+        label = "alpha",
+    )
+
+    Column(
+        modifier = modifier
+            .fillMaxWidth()
+            .heightIn(min = 180.dp)
+            .clip(RoundedCornerShape(16.dp))
+            .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha))
+            .padding(16.dp),
+        verticalArrangement = Arrangement.spacedBy(12.dp),
+    ) {
+        Box(Modifier.fillMaxWidth(0.4f).height(20.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
+        Box(Modifier.fillMaxWidth().height(36.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
+        Box(Modifier.fillMaxWidth(0.7f).height(20.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
+    }
+}
+```

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

@@ -0,0 +1,2204 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# ╔══════════════════════════════════════════════════════════════════╗
+# ║  Agent Skill Scanner v4                                         ║
+# ║  Validates skill packages against the agentskills.io spec       ║
+# ║  and best practices. Runs locally and in CI (GitHub Actions).   ║
+# ║                                                                 ║
+# ║  Spec:  https://agentskills.io/specification                    ║
+# ║  Guide: https://agentskills.io/skill-creation                   ║
+# ║                                                                 ║
+# ║  Token Estimation:                                              ║
+# ║  - Uses character/4 approximation (industry standard)           ║
+# ║  - Accurate within ~10% for English text                        ║
+# ║  - Based on OpenAI tiktoken cl100k_base encoding                ║
+# ║                                                                 ║
+# ║  Quality Score: 0-100 across 5 dimensions                       ║
+# ║  - Description Quality (30)  · Spec Compliance (20)             ║
+# ║  - Instruction Clarity (25)  · Progressive Disclosure (15)      ║
+# ║  - Security (10)                                                ║
+# ╚══════════════════════════════════════════════════════════════════╝
+#
+# Usage:
+#   ./scripts/validate.sh              # full scan
+#   ./scripts/validate.sh --help       # show usage
+#
+# Environment:
+#   CI=true    — emits GitHub Actions annotations (auto-detected)
+#   NO_COLOR=1 — disable colored output
+
+SCAN_START=$SECONDS
+
+ERRORS=0
+WARNINGS=0
+INFO_COUNT=0
+CHECKS_RUN=0
+TOTAL_CHECKS=15
+CURRENT_SECTION=""
+CI="${CI:-false}"
+NO_COLOR="${NO_COLOR:-0}"
+OUTPUT_MODE="terminal"
+SKILL_FILE="SKILL.md"
+SKILL_DIR="$(basename "$(pwd)")"
+
+FINDINGS_FILE=$(mktemp)
+trap 'rm -f "$FINDINGS_FILE"' EXIT
+
+# ── Spec limits (agentskills.io/specification) ─────────────────────
+
+NAME_MAX_LEN=64
+DESC_MAX_LEN=1024
+DESC_MIN_USEFUL=30
+BODY_MAX_LINES=500
+TOKEN_BUDGET=5000
+COMPAT_MAX_LEN=500
+
+# ── Token budget zones ────────────────────────────────────────────
+
+TOKEN_SAFE_ZONE=3500
+TOKEN_WARN_ZONE=5000
+TOKEN_DANGER_ZONE=8000
+
+REF_TOKEN_SAFE=2000
+REF_TOKEN_WARN=3500
+REF_TOKEN_DANGER=4500
+
+TOTAL_TOKEN_SAFE=50000
+TOTAL_TOKEN_WARN=100000
+TOTAL_TOKEN_DANGER=200000
+
+# ── Score trackers (populated during checks, consumed by scoring) ──
+
+_S_HAS_NAME=false
+_S_NAME_OK=false
+_S_HAS_DESC=false
+_S_DESC_LEN=0
+_S_DESC_WHAT=false
+_S_DESC_WHEN=false
+_S_DESC_FIRST_PERSON=false
+_S_DESC_GENERIC=false
+_S_DESC_NEGATIVES=false
+_S_CODE_BLOCKS=0
+_S_HEADINGS=0
+_S_REF_LINKS=0
+_S_BODY_LINES=0
+_S_BODY_TOKENS=0
+_S_HAS_REFS_DIR=false
+_S_REFS_LINKED=0
+_S_REFS_TOTAL=0
+_S_FENCES_OK=true
+_S_SECRETS=0
+_S_HARDCODED=0
+_S_DANGEROUS=0
+
+QUALITY_SCORE=0
+QUALITY_GRADE="F"
+QUALITY_DESC=0
+QUALITY_CLARITY=0
+QUALITY_SPEC=0
+QUALITY_PROGRESSIVE=0
+QUALITY_SECURITY=0
+
+# ── Colors & formatting ───────────────────────────────────────────
+
+if [ "$NO_COLOR" = "1" ]; then
+  _red()         { printf '%s' "$*"; }
+  _green()       { printf '%s' "$*"; }
+  _yellow()      { printf '%s' "$*"; }
+  _blue()        { printf '%s' "$*"; }
+  _cyan()        { printf '%s' "$*"; }
+  _magenta()     { printf '%s' "$*"; }
+  _bold()        { printf '%s' "$*"; }
+  _dim()         { printf '%s' "$*"; }
+  _bold_green()  { printf '%s' "$*"; }
+  _bold_red()    { printf '%s' "$*"; }
+  _bold_yellow() { printf '%s' "$*"; }
+  _bold_cyan()   { printf '%s' "$*"; }
+else
+  _red()         { printf "\033[0;31m%s\033[0m" "$*"; }
+  _green()       { printf "\033[0;32m%s\033[0m" "$*"; }
+  _yellow()      { printf "\033[0;33m%s\033[0m" "$*"; }
+  _blue()        { printf "\033[0;34m%s\033[0m" "$*"; }
+  _cyan()        { printf "\033[0;36m%s\033[0m" "$*"; }
+  _magenta()     { printf "\033[0;35m%s\033[0m" "$*"; }
+  _bold()        { printf "\033[1m%s\033[0m" "$*"; }
+  _dim()         { printf "\033[2m%s\033[0m" "$*"; }
+  _bold_green()  { printf "\033[1;32m%s\033[0m" "$*"; }
+  _bold_red()    { printf "\033[1;31m%s\033[0m" "$*"; }
+  _bold_yellow() { printf "\033[1;33m%s\033[0m" "$*"; }
+  _bold_cyan()   { printf "\033[1;36m%s\033[0m" "$*"; }
+fi
+
+# ── Logging ────────────────────────────────────────────────────────
+
+_error() {
+  ERRORS=$((ERRORS + 1))
+  [ "$OUTPUT_MODE" = "terminal" ] && echo "  $(_red "✗ ERROR") $*" || true
+  echo "ERROR|${CURRENT_SECTION}|$*" >> "$FINDINGS_FILE"
+}
+
+_warn() {
+  WARNINGS=$((WARNINGS + 1))
+  [ "$OUTPUT_MODE" = "terminal" ] && echo "  $(_yellow "! WARN ") $*" || true
+  echo "WARN|${CURRENT_SECTION}|$*" >> "$FINDINGS_FILE"
+}
+
+_pass() {
+  [ "$OUTPUT_MODE" = "terminal" ] && echo "  $(_green "✓ PASS ") $*" || true
+}
+
+_info() {
+  INFO_COUNT=$((INFO_COUNT + 1))
+  [ "$OUTPUT_MODE" = "terminal" ] && echo "  $(_blue "ℹ INFO ") $*" || true
+  echo "INFO|${CURRENT_SECTION}|$*" >> "$FINDINGS_FILE"
+}
+
+_detail() {
+  [ "$OUTPUT_MODE" = "terminal" ] && echo "         $(_dim "$*")" || true
+}
+
+ci_annotate() {
+  local level="$1"; shift
+  if [ "$CI" = "true" ]; then
+    echo "::${level} $*"
+  fi
+}
+
+section() {
+  CHECKS_RUN=$((CHECKS_RUN + 1))
+  CURRENT_SECTION="$1"
+  if [ "$OUTPUT_MODE" = "terminal" ]; then
+    echo ""
+    echo "  $(_bold_cyan "[$CHECKS_RUN/$TOTAL_CHECKS]") $(_bold "$1")"
+    echo "  $(_dim "$(printf '%.0s─' $(seq 1 60))")"
+  fi
+}
+
+file_lines() { wc -l < "$1" | tr -d ' '; }
+file_chars() { wc -c < "$1" | tr -d ' '; }
+file_words() { wc -w < "$1" | tr -d ' '; }
+
+estimate_tokens() {
+  local chars
+  chars=$(file_chars "$1")
+  echo $(( chars / 4 ))
+}
+
+estimate_tokens_detailed() {
+  local file="$1"
+  local chars words lines char_tokens word_tokens
+  chars=$(file_chars "$file")
+  words=$(file_words "$file")
+  lines=$(file_lines "$file")
+  char_tokens=$(( chars / 4 ))
+  word_tokens=$(( (words * 13) / 10 ))
+  echo "$char_tokens|$chars|$words|$lines|$word_tokens"
+}
+
+token_zone_color() {
+  local tokens="$1" safe="$2" warn="$3"
+  if [ "$tokens" -le "$safe" ]; then
+    echo "green"
+  elif [ "$tokens" -le "$warn" ]; then
+    echo "yellow"
+  else
+    echo "red"
+  fi
+}
+
+format_tokens_colored() {
+  local tokens="$1" safe="$2" warn="$3" label="${4:-tokens}"
+  local zone
+  zone=$(token_zone_color "$tokens" "$safe" "$warn")
+  case "$zone" in
+    green)  printf "%s" "$(_green "~$tokens $label")" ;;
+    yellow) printf "%s" "$(_yellow "~$tokens $label")" ;;
+    red)    printf "%s" "$(_red "~$tokens $label")" ;;
+  esac
+}
+
+token_zone_indicator() {
+  local tokens="$1" safe="$2" warn="$3"
+  local zone pct
+  zone=$(token_zone_color "$tokens" "$safe" "$warn")
+  pct=$(( (tokens * 100) / warn ))
+  case "$zone" in
+    green)
+      if [ "$NO_COLOR" = "1" ]; then echo "[SAFE $pct%]"
+      else echo "$(_green "[SAFE $pct%]")"; fi ;;
+    yellow)
+      if [ "$NO_COLOR" = "1" ]; then echo "[WARN $pct%]"
+      else echo "$(_yellow "[WARN $pct%]")"; fi ;;
+    red)
+      if [ "$NO_COLOR" = "1" ]; then echo "[HIGH $pct%]"
+      else echo "$(_red "[HIGH $pct%]")"; fi ;;
+  esac
+}
+
+# ── Visual helpers ─────────────────────────────────────────────────
+
+bar_gauge() {
+  local value="$1" max="$2" width="${3:-20}"
+  local pct filled empty bar=""
+  if [ "$max" -le 0 ]; then pct=0
+  else pct=$(( (value * 100) / max )); fi
+  [ "$pct" -gt 100 ] && pct=100
+  filled=$(( (pct * width) / 100 ))
+  empty=$(( width - filled ))
+  local i
+  for ((i=0; i<filled; i++)); do bar+="█"; done
+  for ((i=0; i<empty; i++)); do bar+="░"; done
+  echo "$bar"
+}
+
+bar_gauge_colored() {
+  local value="$1" max="$2" width="${3:-20}" safe="${4:-0}" warn="${5:-0}"
+  local pct gauge
+  if [ "$max" -le 0 ]; then pct=0
+  else pct=$(( (value * 100) / max )); fi
+  [ "$pct" -gt 100 ] && pct=100
+  gauge=$(bar_gauge "$value" "$max" "$width")
+
+  if [ "$safe" -gt 0 ] && [ "$warn" -gt 0 ]; then
+    local zone
+    zone=$(token_zone_color "$value" "$safe" "$warn")
+    case "$zone" in
+      green)  printf "%s %s" "$(_green "$gauge")" "$(_green "${pct}%")" ;;
+      yellow) printf "%s %s" "$(_yellow "$gauge")" "$(_yellow "${pct}%")" ;;
+      red)    printf "%s %s" "$(_red "$gauge")" "$(_red "${pct}%")" ;;
+    esac
+  else
+    printf "%s %d%%" "$gauge" "$pct"
+  fi
+}
+
+letter_grade() {
+  local score="$1"
+  if   [ "$score" -ge 95 ]; then echo "A+"
+  elif [ "$score" -ge 90 ]; then echo "A"
+  elif [ "$score" -ge 85 ]; then echo "A-"
+  elif [ "$score" -ge 80 ]; then echo "B+"
+  elif [ "$score" -ge 75 ]; then echo "B"
+  elif [ "$score" -ge 70 ]; then echo "B-"
+  elif [ "$score" -ge 65 ]; then echo "C+"
+  elif [ "$score" -ge 60 ]; then echo "C"
+  elif [ "$score" -ge 55 ]; then echo "C-"
+  elif [ "$score" -ge 50 ]; then echo "D"
+  else echo "F"
+  fi
+}
+
+grade_color() {
+  local score="$1" grade
+  grade=$(letter_grade "$score")
+  if   [ "$score" -ge 85 ]; then printf "%s" "$(_bold_green "$grade")"
+  elif [ "$score" -ge 65 ]; then printf "%s" "$(_bold_yellow "$grade")"
+  else printf "%s" "$(_bold_red "$grade")"
+  fi
+}
+
+safe_count() {
+  local result
+  result=$("$@" 2>/dev/null | tr -d ' ' || true)
+  if [ -z "$result" ] || ! [[ "$result" =~ ^[0-9]+$ ]]; then
+    echo "0"
+  else
+    echo "$result"
+  fi
+}
+
+# ════════════════════════════════════════════════════════════════════
+#  CHECKS
+# ════════════════════════════════════════════════════════════════════
+
+# ── [1] Skill Structure ───────────────────────────────────────────
+
+check_structure() {
+  section "Skill Structure"
+
+  if [ ! -f "$SKILL_FILE" ]; then
+    _error "SKILL.md not found — required by agentskills.io spec"
+    ci_annotate "error" "file=SKILL.md::SKILL.md not found"
+    return
+  fi
+  _pass "SKILL.md ($(file_lines "$SKILL_FILE") lines, $(file_chars "$SKILL_FILE") bytes)"
+
+  if [ ! -f "README.md" ]; then
+    _warn "README.md missing — recommended for discoverability and GitHub rendering"
+    ci_annotate "warning" "file=README.md::README.md missing (recommended)"
+  else
+    _pass "README.md ($(file_lines README.md) lines)"
+  fi
+
+  local dirs=("references" "scripts" "assets" "agents")
+  for dir in "${dirs[@]}"; do
+    if [ -d "$dir" ]; then
+      local count
+      count=$(find "$dir" -type f | wc -l | tr -d ' ')
+      _pass "$dir/ ($count files)"
+      if [ "$dir" = "references" ]; then
+        _S_HAS_REFS_DIR=true
+      fi
+    fi
+  done
+
+  if [ ! -d "references" ]; then
+    _info "No references/ directory"
+    _detail "Add reference docs for progressive disclosure of complex content"
+  fi
+
+  if [ -f "LICENSE" ] || [ -f "LICENSE.md" ] || [ -f "LICENSE.txt" ]; then
+    _pass "License file found"
+  fi
+}
+
+# ── [2] Frontmatter ───────────────────────────────────────────────
+
+check_frontmatter() {
+  [ ! -f "$SKILL_FILE" ] && return
+
+  section "Frontmatter & Description Quality"
+
+  local first_line
+  first_line=$(head -1 "$SKILL_FILE")
+  if [ "$first_line" != "---" ]; then
+    _error "Line 1: Expected '---' delimiter, found: '$first_line'"
+    ci_annotate "error" "file=$SKILL_FILE,line=1::Missing YAML frontmatter delimiter"
+    return
+  fi
+
+  local frontmatter
+  frontmatter=$(sed -n '2,/^---$/p' "$SKILL_FILE" | sed '$d')
+
+  # ── name ──
+  local name
+  name=$(echo "$frontmatter" | grep '^name:' | head -1 | sed 's/^name:[[:space:]]*//')
+
+  if [ -z "$name" ]; then
+    _error "Required field 'name' missing"
+    ci_annotate "error" "file=$SKILL_FILE::Missing required 'name' field"
+  else
+    _S_HAS_NAME=true
+    local name_len=${#name}
+    local name_ok=true
+
+    if [ "$name_len" -gt "$NAME_MAX_LEN" ]; then
+      _error "name '$name' is $name_len chars (max $NAME_MAX_LEN)"
+      ci_annotate "error" "file=$SKILL_FILE::name exceeds $NAME_MAX_LEN chars"
+      name_ok=false
+    fi
+
+    if echo "$name" | grep -qE '[A-Z]'; then
+      _error "name '$name' has uppercase — spec requires lowercase only"
+      ci_annotate "error" "file=$SKILL_FILE::name must be lowercase"
+      name_ok=false
+    fi
+
+    if echo "$name" | grep -qE '[^a-z0-9-]'; then
+      _error "name '$name' has invalid chars — only a-z, 0-9, hyphens allowed"
+      ci_annotate "error" "file=$SKILL_FILE::name contains invalid characters"
+      name_ok=false
+    fi
+
+    if echo "$name" | grep -qE '^-|-$'; then
+      _error "name '$name' starts or ends with hyphen"
+      ci_annotate "error" "file=$SKILL_FILE::name starts/ends with hyphen"
+      name_ok=false
+    fi
+
+    if echo "$name" | grep -qF -- '--'; then
+      _error "name '$name' has consecutive hyphens (--)"
+      ci_annotate "error" "file=$SKILL_FILE::name has consecutive hyphens"
+      name_ok=false
+    fi
+
+    if [ "$name_ok" = true ]; then
+      _pass "name: '$name' ($name_len chars)"
+      _S_NAME_OK=true
+    fi
+
+    if [ "$name" != "$SKILL_DIR" ]; then
+      _warn "name '$name' ≠ directory '$SKILL_DIR'"
+      _detail "Spec: name should match parent directory name"
+      ci_annotate "warning" "file=$SKILL_FILE::name doesn't match directory name"
+    else
+      _pass "name matches directory name"
+    fi
+  fi
+
+  # ── description ──
+  local desc_line desc=""
+  desc_line=$(echo "$frontmatter" | grep -n '^description:' | head -1 | cut -d: -f1)
+
+  if [ -z "$desc_line" ]; then
+    _error "Required field 'description' missing"
+    ci_annotate "error" "file=$SKILL_FILE::Missing required 'description' field"
+  else
+    _S_HAS_DESC=true
+    local inline_desc
+    inline_desc=$(echo "$frontmatter" | sed -n "${desc_line}p" | sed 's/^description:[[:space:]]*//')
+
+    if [ -n "$inline_desc" ] && ! echo "$inline_desc" | grep -qE '^\s*[>|]\s*$'; then
+      desc="$inline_desc"
+    else
+      desc=$(echo "$frontmatter" | sed -n "$((desc_line+1)),\$p" | sed '/^[a-zA-Z_-]*:/,$d' | tr '\n' ' ' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
+    fi
+
+    local desc_len=${#desc}
+    _S_DESC_LEN=$desc_len
+
+    if [ "$desc_len" -eq 0 ]; then
+      _error "description is empty"
+      ci_annotate "error" "file=$SKILL_FILE::description is empty"
+    else
+      if [ "$desc_len" -gt "$DESC_MAX_LEN" ]; then
+        _warn "description is $desc_len chars (spec max: $DESC_MAX_LEN)"
+        ci_annotate "warning" "file=$SKILL_FILE::description exceeds $DESC_MAX_LEN chars"
+      else
+        _pass "description length: $desc_len chars (limit: $DESC_MAX_LEN)"
+      fi
+
+      if [ "$desc_len" -lt "$DESC_MIN_USEFUL" ]; then
+        _warn "description is very short ($desc_len chars) — likely won't trigger well"
+        _detail "Good: 'Extract text and tables from PDF files, fill forms, merge"
+        _detail "       documents. Use when working with PDF documents.'"
+        _detail "Bad:  'Helps with PDFs.'"
+        ci_annotate "warning" "file=$SKILL_FILE::description too short for reliable triggering"
+      fi
+
+      # WHAT and WHEN analysis
+      local has_what=false has_when=false
+      if echo "$desc" | grep -qiE 'build|create|generate|extract|analyze|process|manage|handle|configure|review|refactor|optimize|test|debug|deploy|format|validate|convert|transform|monitor|implement'; then
+        has_what=true
+        _S_DESC_WHAT=true
+      fi
+      if echo "$desc" | grep -qiE 'use when|when working|when the user|when handling|if the user|for tasks|for working|designed for|use this|use for'; then
+        has_when=true
+        _S_DESC_WHEN=true
+      fi
+
+      if [ "$has_what" = true ] && [ "$has_when" = true ]; then
+        _pass "description covers WHAT and WHEN to use"
+      elif [ "$has_what" = false ] && [ "$has_when" = false ]; then
+        _warn "description may lack WHAT the skill does and WHEN to use it"
+        _detail "Spec: 'Describes what the skill does and when to use it'"
+      elif [ "$has_when" = false ]; then
+        _info "description explains WHAT but could clarify WHEN to trigger"
+        _detail "Adding 'Use when...' helps agents decide when to activate"
+      fi
+
+      # ── [NEW] First-person voice ──
+      if echo "$desc" | grep -qiE '\bI can\b|\bI will\b|\bI help\b|\bI am\b|\bI provide\b|\bmy skill\b'; then
+        _warn "description uses first-person voice ('I can', 'I will', etc.)"
+        _detail "Descriptions are injected into the agent system prompt — first-person"
+        _detail "mixes viewpoints and confuses agent reasoning. Use third person instead"
+        ci_annotate "warning" "file=$SKILL_FILE::description uses first-person voice"
+        _S_DESC_FIRST_PERSON=true
+      else
+        _pass "description uses correct voice (not first-person)"
+      fi
+
+      # ── [NEW] Generic verb detection ──
+      local generic_matches
+      generic_matches=$(echo "$desc" | grep -oiE '\b(manage|handle|deal with|work with|help with|assist with|take care of)\b' 2>/dev/null | head -3 || true)
+      if [ -n "$generic_matches" ]; then
+        local generic_list
+        generic_list=$(echo "$generic_matches" | tr '\n' ', ' | sed 's/,$//')
+        _info "description contains generic verbs: $generic_list"
+        _detail "Specific verbs (extract, generate, validate) improve trigger precision"
+        _S_DESC_GENERIC=true
+      else
+        _pass "description uses specific action verbs"
+      fi
+
+      # ── [NEW] Negative trigger detection ──
+      if echo "$desc" | grep -qiE 'not for|not designed for|do not use|does not|don.t use'; then
+        _pass "description includes boundary markers (negative triggers)"
+        _S_DESC_NEGATIVES=true
+      else
+        _info "no negative triggers ('NOT for...', 'Do not use when...')"
+        _detail "Negative triggers prevent mis-activation when many skills are loaded"
+      fi
+    fi
+  fi
+
+  # ── optional fields ──
+  echo ""
+  _detail "Optional fields:"
+
+  if echo "$frontmatter" | grep -q '^license:'; then
+    local license_val
+    license_val=$(echo "$frontmatter" | grep '^license:' | sed 's/^license:[[:space:]]*//')
+    _pass "license: $license_val"
+  else
+    _info "No license field — recommended for shared/public skills"
+  fi
+
+  if echo "$frontmatter" | grep -q '^compatibility:'; then
+    local compat
+    compat=$(echo "$frontmatter" | grep '^compatibility:' | sed 's/^compatibility:[[:space:]]*//')
+    local compat_len=${#compat}
+    if [ "$compat_len" -gt "$COMPAT_MAX_LEN" ]; then
+      _warn "compatibility is $compat_len chars (spec max: $COMPAT_MAX_LEN)"
+    else
+      _pass "compatibility field present ($compat_len chars)"
+    fi
+  fi
+
+  if echo "$frontmatter" | grep -q '^metadata:'; then
+    _pass "metadata field present"
+    if echo "$frontmatter" | grep -q '^\s*version:'; then
+      _pass "metadata.version set"
+    fi
+    if echo "$frontmatter" | grep -q '^\s*author:'; then
+      _pass "metadata.author set"
+    fi
+  fi
+
+  if echo "$frontmatter" | grep -q '^allowed-tools:'; then
+    _pass "allowed-tools field present (experimental)"
+  fi
+}
+
+# ── [3] Body Content & Progressive Disclosure ─────────────────────
+
+check_body() {
+  [ ! -f "$SKILL_FILE" ] && return
+
+  section "Body Content & Progressive Disclosure"
+
+  local body_start
+  body_start=$(grep -n '^---$' "$SKILL_FILE" | sed -n '2p' | cut -d: -f1)
+
+  if [ -z "$body_start" ]; then
+    _error "No closing frontmatter delimiter (---) found"
+    ci_annotate "error" "file=$SKILL_FILE::Missing closing frontmatter delimiter"
+    return
+  fi
+
+  local body_lines token_est
+  body_lines=$(tail -n +"$((body_start + 1))" "$SKILL_FILE" | wc -l | tr -d ' ')
+  token_est=$(estimate_tokens "$SKILL_FILE")
+
+  _S_BODY_LINES=$body_lines
+  _S_BODY_TOKENS=$token_est
+
+  # Line count with bar gauge
+  if [ "$body_lines" -eq 0 ]; then
+    _error "SKILL.md body is empty — agents need instructions"
+    ci_annotate "error" "file=$SKILL_FILE::Empty body"
+  elif [ "$body_lines" -gt "$BODY_MAX_LINES" ]; then
+    _warn "Body: $body_lines lines (spec recommends <$BODY_MAX_LINES)"
+    _detail "Move detailed content to references/ for on-demand loading"
+    ci_annotate "warning" "file=$SKILL_FILE::Body exceeds $BODY_MAX_LINES lines"
+  else
+    _pass "Body: $body_lines lines (limit: $BODY_MAX_LINES)"
+  fi
+  local lines_gauge
+  lines_gauge=$(bar_gauge_colored "$body_lines" "$BODY_MAX_LINES" 20 "$(( BODY_MAX_LINES * 70 / 100 ))" "$BODY_MAX_LINES")
+  [ "$OUTPUT_MODE" = "terminal" ] && echo "         $lines_gauge" || true
+
+  # Token budget with bar gauge
+  if [ "$token_est" -gt "$TOKEN_BUDGET" ]; then
+    _warn "~$token_est tokens (spec recommends <$TOKEN_BUDGET on activation)"
+    _detail "Progressive disclosure: SKILL.md body loads fully on activation"
+    _detail "Keep instructions concise, move reference material to references/"
+    ci_annotate "warning" "file=$SKILL_FILE::Estimated $token_est tokens exceeds $TOKEN_BUDGET budget"
+  else
+    _pass "~$token_est tokens (budget: $TOKEN_BUDGET)"
+  fi
+  local token_gauge
+  token_gauge=$(bar_gauge_colored "$token_est" "$TOKEN_DANGER_ZONE" 20 "$TOKEN_SAFE_ZONE" "$TOKEN_WARN_ZONE")
+  [ "$OUTPUT_MODE" = "terminal" ] && echo "         $token_gauge" || true
+
+  # Structure analysis
+  local heading_count
+  heading_count=$(tail -n +"$((body_start + 1))" "$SKILL_FILE" | grep -c '^#' 2>/dev/null || true)
+  heading_count=${heading_count:-0}
+  _S_HEADINGS=$heading_count
+
+  if [ "$heading_count" -eq 0 ]; then
+    _warn "No headings in body — add structure for readability"
+  else
+    _pass "$heading_count heading(s) providing structure"
+  fi
+
+  # Code examples
+  local fence_pattern='```'
+  local code_fence_count
+  code_fence_count=$(grep -cF "$fence_pattern" "$SKILL_FILE" 2>/dev/null || true)
+  code_fence_count=${code_fence_count:-0}
+  local code_pairs=$(( code_fence_count / 2 ))
+  _S_CODE_BLOCKS=$code_pairs
+
+  local inline_code_count
+  inline_code_count=$(grep -c '`[^`]' "$SKILL_FILE" 2>/dev/null || true)
+  inline_code_count=${inline_code_count:-0}
+
+  if [ "$code_pairs" -gt 0 ]; then
+    _pass "$code_pairs fenced code example(s) in body"
+  elif [ "$inline_code_count" -gt 0 ]; then
+    _pass "$inline_code_count line(s) with inline code references"
+  else
+    _info "No code examples in body — consider adding for clarity"
+  fi
+
+  # Reference links (progressive disclosure pattern)
+  local ref_link_count
+  ref_link_count=$(tail -n +"$((body_start + 1))" "$SKILL_FILE" | grep -cE '\]\(references/' 2>/dev/null || true)
+  ref_link_count=${ref_link_count:-0}
+  _S_REF_LINKS=$ref_link_count
+
+  if [ "$ref_link_count" -gt 0 ]; then
+    _pass "$ref_link_count reference link(s) — using progressive disclosure"
+  elif [ -d "references" ]; then
+    local ref_file_count
+    ref_file_count=$(find references -name '*.md' -type f | wc -l | tr -d ' ')
+    if [ "$ref_file_count" -gt 0 ]; then
+      _warn "references/ has $ref_file_count files but body has no links to them"
+      _detail "Link references from SKILL.md body so agents can load them on demand"
+    fi
+  fi
+}
+
+# ── [4] Internal Links ────────────────────────────────────────────
+
+check_links() {
+  [ ! -f "$SKILL_FILE" ] && return
+
+  section "Internal Links"
+
+  local link_data
+  link_data=$(grep -nF '](' "$SKILL_FILE" || true)
+
+  if [ -z "$link_data" ]; then
+    _info "No internal links in SKILL.md"
+    return
+  fi
+
+  local checked=0 broken=0
+
+  while IFS= read -r match; do
+    local line_num line_content
+    line_num=$(echo "$match" | cut -d: -f1)
+    line_content=$(echo "$match" | cut -d: -f2-)
+
+    local targets
+    targets=$(echo "$line_content" | tr ']' '\n' | grep '^(' | sed 's/^(\([^)]*\)).*/\1/' | sed '/^$/d')
+
+    [ -z "$targets" ] && continue
+
+    while IFS= read -r link_path; do
+      case "$link_path" in http*|https*|"#"*|"") continue ;; esac
+
+      local file_path
+      file_path=$(echo "$link_path" | cut -d'#' -f1)
+      [ -z "$file_path" ] && continue
+
+      checked=$((checked + 1))
+      if [ -f "$file_path" ]; then
+        _pass "Line $line_num: $link_path → $(file_lines "$file_path") lines"
+      else
+        _error "Line $line_num: $link_path → FILE NOT FOUND"
+        ci_annotate "error" "file=$SKILL_FILE,line=$line_num::Broken link: $link_path"
+        broken=$((broken + 1))
+      fi
+    done <<< "$targets"
+  done <<< "$link_data"
+
+  echo ""
+  _detail "Checked $checked link(s), $broken broken"
+}
+
+# ── [5] Reference Files ──────────────────────────────────────────
+
+check_references() {
+  [ ! -d "references" ] && return
+
+  section "Reference Files"
+
+  local ref_links=""
+  if [ -f "$SKILL_FILE" ]; then
+    ref_links=$(grep -oE '\]\(references/[^)]+\)' "$SKILL_FILE" | sed 's/\](\(.*\))/\1/' | cut -d'#' -f1 | sort -u || true)
+  fi
+
+  local total=0 linked=0 orphaned=0 empty=0
+
+  echo ""
+  printf "  $(_dim "  %-35s  %-8s  %-8s  %s")\n" "FILE" "LINES" "STATUS" ""
+  echo "  $(_dim "  $(printf '%.0s─' $(seq 1 56))")"
+
+  while IFS= read -r file; do
+    total=$((total + 1))
+    local lines
+    lines=$(file_lines "$file")
+
+    if [ "$lines" -eq 0 ]; then
+      printf "  $(_yellow "  %-35s  %-8s  %-8s")\n" "$(basename "$file")" "0" "EMPTY"
+      _warn "$file is empty (0 lines)"
+      ci_annotate "warning" "file=$file::Empty reference file"
+      empty=$((empty + 1))
+      continue
+    fi
+
+    if echo "$ref_links" | grep -qF "$file"; then
+      linked=$((linked + 1))
+      printf "  $(_green "  %-35s  %-8s  %-8s")\n" "$(basename "$file")" "$lines" "LINKED"
+    else
+      orphaned=$((orphaned + 1))
+      printf "  $(_yellow "  %-35s  %-8s  %-8s")\n" "$(basename "$file")" "$lines" "ORPHAN"
+      _warn "$file — $(_yellow "orphaned")"
+      _detail "Not linked from SKILL.md — agents won't discover this file"
+      ci_annotate "warning" "file=$file::Not linked from SKILL.md (orphaned)"
+    fi
+  done < <(find references -name '*.md' -type f | sort)
+
+  _S_REFS_TOTAL=$total
+  _S_REFS_LINKED=$linked
+
+  # Missing references (linked in SKILL.md but don't exist)
+  local missing=0
+  if [ -n "$ref_links" ]; then
+    for ref in $ref_links; do
+      if [ ! -f "$ref" ]; then
+        _error "$ref → linked in SKILL.md but file is missing"
+        ci_annotate "error" "file=$ref::Referenced in SKILL.md but does not exist"
+        missing=$((missing + 1))
+      fi
+    done
+  fi
+
+  echo ""
+  echo "  $(_dim "  ┌──────────────────────────────────────────┐")"
+  printf "  $(_dim "  │") Total: %-4s  Linked: %-4s  Orphaned: %-4s$(_dim "│")\n" "$total" "$linked" "$orphaned"
+  printf "  $(_dim "  │") Empty: %-4s  Missing: %-4s               $(_dim "│")\n" "$empty" "$missing"
+  echo "  $(_dim "  └──────────────────────────────────────────┘")"
+}
+
+# ── [6] Markdown Syntax ──────────────────────────────────────────
+
+check_markdown() {
+  section "Markdown Syntax"
+
+  local pattern file_count=0 issues=0
+  pattern='```'
+
+  while IFS= read -r file; do
+    file_count=$((file_count + 1))
+    local count
+    count=$(grep -cF "$pattern" "$file" 2>/dev/null || true)
+    count=${count:-0}
+
+    if [ "$count" -gt 0 ] && [ $((count % 2)) -ne 0 ]; then
+      _warn "$file — unclosed code block ($count fences)"
+      grep -nF "$pattern" "$file" 2>/dev/null | while IFS= read -r m; do
+        _detail "Line $(echo "$m" | cut -d: -f1): $(echo "$m" | cut -d: -f2-)"
+      done
+      ci_annotate "warning" "file=$file::Unclosed code block ($count fences)"
+      issues=$((issues + 1))
+    fi
+  done < <(find . -name '*.md' -not -path './.git/*' | sort)
+
+  if [ "$issues" -eq 0 ]; then
+    _pass "All $file_count markdown files have balanced code fences"
+  else
+    _S_FENCES_OK=false
+  fi
+}
+
+# ── [7] Reference Nesting ────────────────────────────────────────
+
+check_reference_depth() {
+  [ ! -d "references" ] && return
+
+  section "Reference Nesting"
+
+  local deep_refs=0
+  while IFS= read -r file; do
+    local nested
+    nested=$(grep -cE '\]\(references/' "$file" 2>/dev/null || true)
+    if [ "$nested" -gt 0 ]; then
+      _warn "$file → $nested cross-reference(s) to other reference files"
+      _detail "Spec: keep file references one level deep from SKILL.md"
+      ci_annotate "warning" "file=$file::Cross-references between reference files"
+      deep_refs=$((deep_refs + 1))
+    fi
+  done < <(find references -name '*.md' -type f | sort)
+
+  if [ "$deep_refs" -eq 0 ]; then
+    _pass "No nested reference chains — clean one-level structure"
+  else
+    _detail "$deep_refs file(s) with cross-references"
+  fi
+}
+
+# ── [8] Scripts Validation ───────────────────────────────────────
+
+check_scripts() {
+  [ ! -d "scripts" ] && return
+
+  section "Scripts"
+
+  local total=0 executable=0 not_executable=0 documented=0
+
+  while IFS= read -r script; do
+    total=$((total + 1))
+    local basename_script
+    basename_script=$(basename "$script")
+
+    if [ -x "$script" ]; then
+      executable=$((executable + 1))
+      _pass "$script (executable, $(file_lines "$script") lines)"
+    else
+      not_executable=$((not_executable + 1))
+      _warn "$script is not executable"
+      _detail "Run: chmod +x $script"
+      ci_annotate "warning" "file=$script::Script is not executable"
+    fi
+
+    if [ -f "$SKILL_FILE" ] && grep -qF "$basename_script" "$SKILL_FILE"; then
+      documented=$((documented + 1))
+    fi
+  done < <(find scripts -type f | sort)
+
+  if [ "$total" -gt 0 ] && [ "$documented" -eq 0 ] && [ -f "$SKILL_FILE" ]; then
+    _info "No scripts referenced in SKILL.md body"
+    _detail "Document available scripts so agents know what tools they can run"
+  elif [ "$documented" -gt 0 ]; then
+    _pass "$documented of $total script(s) documented in SKILL.md"
+  fi
+}
+
+# ── [9] Package Integrity ────────────────────────────────────────
+
+check_repo_hygiene() {
+  section "Repository Hygiene"
+
+  local sensitive
+  sensitive=$(find . -maxdepth 3 \
+    \( -name '.env' -o -name '*.key' -o -name '*.pem' -o -name 'credentials*' \) \
+    -not -path './.git/*' 2>/dev/null || true)
+
+  if [ -n "$sensitive" ]; then
+    _warn "Possible sensitive files detected"
+    echo "$sensitive" | while IFS= read -r f; do
+      _detail "  $f"
+    done
+  else
+    _pass "No sensitive files (.env, .key, .pem, credentials)"
+  fi
+
+  local artifacts
+  artifacts=$(find . -maxdepth 3 \
+    \( -name 'node_modules' -o -name '__pycache__' -o -name '.DS_Store' \) \
+    -not -path './.git/*' 2>/dev/null || true)
+
+  if [ -n "$artifacts" ]; then
+    _warn "Development artifacts found — add to .gitignore"
+    echo "$artifacts" | while IFS= read -r f; do
+      _detail "  $f"
+    done
+  else
+    _pass "No development artifacts (node_modules, __pycache__, .DS_Store)"
+  fi
+}
+
+# ── [10] Token Budget Analysis ────────────────────────────────────
+
+check_token_budget() {
+  section "Token Budget Analysis"
+
+  if [ "$OUTPUT_MODE" = "terminal" ]; then echo ""; fi
+  _detail "Token estimation: chars/4 (tiktoken cl100k_base approximation)"
+  _detail "Zone thresholds based on agentskills.io and context engineering best practices"
+  if [ "$OUTPUT_MODE" = "terminal" ]; then echo ""; fi
+
+  local total_tokens=0
+  local total_chars=0
+  local total_words=0
+  local total_lines=0
+  local file_count=0
+
+  # ── SKILL.md analysis ──
+  if [ -f "$SKILL_FILE" ]; then
+    local skill_data skill_tokens skill_chars skill_words skill_lines
+    skill_data=$(estimate_tokens_detailed "$SKILL_FILE")
+    skill_tokens=$(echo "$skill_data" | cut -d'|' -f1)
+    skill_chars=$(echo "$skill_data" | cut -d'|' -f2)
+    skill_words=$(echo "$skill_data" | cut -d'|' -f3)
+    skill_lines=$(echo "$skill_data" | cut -d'|' -f4)
+
+    local zone_indicator
+    zone_indicator=$(token_zone_indicator "$skill_tokens" "$TOKEN_SAFE_ZONE" "$TOKEN_WARN_ZONE")
+
+    if [ "$OUTPUT_MODE" = "terminal" ]; then
+      echo "  $(_bold "SKILL.md") $zone_indicator"
+      echo "    ├─ $(format_tokens_colored "$skill_tokens" "$TOKEN_SAFE_ZONE" "$TOKEN_WARN_ZONE" "tokens")"
+      local skill_gauge
+      skill_gauge=$(bar_gauge_colored "$skill_tokens" "$TOKEN_DANGER_ZONE" 20 "$TOKEN_SAFE_ZONE" "$TOKEN_WARN_ZONE")
+      echo "    ├─ $skill_gauge"
+      echo "    ├─ $skill_chars chars | $skill_words words | $skill_lines lines"
+      echo "    └─ Budget: $TOKEN_WARN_ZONE tokens recommended"
+    fi
+
+    total_tokens=$((total_tokens + skill_tokens))
+    total_chars=$((total_chars + skill_chars))
+    total_words=$((total_words + skill_words))
+    total_lines=$((total_lines + skill_lines))
+    file_count=$((file_count + 1))
+
+    if [ "$skill_tokens" -gt "$TOKEN_DANGER_ZONE" ]; then
+      _warn "SKILL.md exceeds danger zone (~$skill_tokens tokens > $TOKEN_DANGER_ZONE)"
+      _detail "Consider moving content to references/ for on-demand loading"
+    fi
+  fi
+
+  # ── Reference files analysis ──
+  if [ -d "references" ]; then
+    if [ "$OUTPUT_MODE" = "terminal" ]; then
+      echo ""
+      echo "  $(_bold "Reference Files:")"
+      echo ""
+      printf "    $(_dim "%-35s  %-12s  %-6s  %-6s")\n" "FILE" "ZONE" "TOKENS" "LINES"
+      echo "    $(_dim "$(printf '%.0s─' $(seq 1 62))")"
+    fi
+
+    local ref_total=0
+    local ref_files=0
+    local largest_ref=""
+    local largest_ref_tokens=0
+
+    while IFS= read -r file; do
+      local ref_data ref_tokens ref_chars ref_words ref_lines
+      ref_data=$(estimate_tokens_detailed "$file")
+      ref_tokens=$(echo "$ref_data" | cut -d'|' -f1)
+      ref_chars=$(echo "$ref_data" | cut -d'|' -f2)
+      ref_words=$(echo "$ref_data" | cut -d'|' -f3)
+      ref_lines=$(echo "$ref_data" | cut -d'|' -f4)
+
+      local zone_indicator
+      zone_indicator=$(token_zone_indicator "$ref_tokens" "$REF_TOKEN_SAFE" "$REF_TOKEN_WARN")
+
+      local basename_file
+      basename_file=$(basename "$file")
+      [ "$OUTPUT_MODE" = "terminal" ] && printf "    %-35s  %-12s  ~%-6d  %d\n" "$basename_file" "$(echo "$zone_indicator" | sed 's/\x1b\[[0-9;]*m//g')" "$ref_tokens" "$ref_lines" || true
+
+      ref_total=$((ref_total + ref_tokens))
+      ref_files=$((ref_files + 1))
+      total_tokens=$((total_tokens + ref_tokens))
+      total_chars=$((total_chars + ref_chars))
+      total_words=$((total_words + ref_words))
+      total_lines=$((total_lines + ref_lines))
+      file_count=$((file_count + 1))
+
+      if [ "$ref_tokens" -gt "$largest_ref_tokens" ]; then
+        largest_ref_tokens=$ref_tokens
+        largest_ref="$basename_file"
+      fi
+
+      if [ "$ref_tokens" -gt "$REF_TOKEN_DANGER" ]; then
+        _warn "$basename_file is too large (~$ref_tokens tokens > $REF_TOKEN_DANGER)"
+        _detail "Split into base + advanced files (e.g., topic.md + topic-advanced.md)"
+      elif [ "$ref_tokens" -gt "$REF_TOKEN_WARN" ]; then
+        _warn "$basename_file is getting large (~$ref_tokens tokens > $REF_TOKEN_WARN)"
+        _detail "Tighten prose: tables over paragraphs, cross-link shared patterns, trim tutorial content"
+      fi
+    done < <(find references -name '*.md' -type f | sort)
+
+    if [ "$OUTPUT_MODE" = "terminal" ]; then
+      echo ""
+      echo "    $(_dim "Reference subtotal: ~$ref_total tokens across $ref_files files")"
+      if [ -n "$largest_ref" ]; then
+        echo "    $(_dim "Largest reference: $largest_ref (~$largest_ref_tokens tokens)")"
+      fi
+    fi
+  fi
+
+  # ── README analysis ──
+  if [ -f "README.md" ]; then
+    local readme_data readme_tokens readme_lines
+    readme_data=$(estimate_tokens_detailed "README.md")
+    readme_tokens=$(echo "$readme_data" | cut -d'|' -f1)
+    readme_lines=$(echo "$readme_data" | cut -d'|' -f4)
+
+    if [ "$OUTPUT_MODE" = "terminal" ]; then
+      echo ""
+      echo "  $(_bold "README.md") $(_dim "(not loaded by agents, for humans)")"
+      echo "    └─ ~$readme_tokens tokens | $readme_lines lines"
+    fi
+  fi
+
+  # ── Total package summary ──
+  if [ "$OUTPUT_MODE" = "terminal" ]; then
+    echo ""
+    echo "  $(_bold "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")"
+
+    local total_zone_indicator
+    total_zone_indicator=$(token_zone_indicator "$total_tokens" "$TOTAL_TOKEN_SAFE" "$TOTAL_TOKEN_WARN")
+
+    echo "  $(_bold "TOTAL SKILL PACKAGE") $total_zone_indicator"
+    echo ""
+    echo "    $(format_tokens_colored "$total_tokens" "$TOTAL_TOKEN_SAFE" "$TOTAL_TOKEN_WARN" "tokens") (estimated)"
+    local total_gauge
+    total_gauge=$(bar_gauge_colored "$total_tokens" "$TOTAL_TOKEN_DANGER" 25 "$TOTAL_TOKEN_SAFE" "$TOTAL_TOKEN_WARN")
+    echo "    $total_gauge"
+    echo "    $total_chars chars | $total_words words | $total_lines lines | $file_count files"
+    echo ""
+  fi
+
+  local uses_progressive=false
+  if [ -d "references" ] && [ -f "$SKILL_FILE" ]; then
+    local ref_link_count
+    ref_link_count=$(grep -cE '\]\(references/' "$SKILL_FILE" 2>/dev/null || true)
+    ref_link_count=${ref_link_count:-0}
+    if [ "$ref_link_count" -gt 0 ]; then
+      uses_progressive=true
+    fi
+  fi
+
+  local ctx_200k_pct ctx_128k_pct skill_only_200k_pct
+  ctx_200k_pct=$(( (total_tokens * 100) / 200000 ))
+  ctx_128k_pct=$(( (total_tokens * 100) / 128000 ))
+  if [ -f "$SKILL_FILE" ]; then
+    skill_only_200k_pct=$(( (_S_BODY_TOKENS * 100) / 200000 ))
+  else
+    skill_only_200k_pct=0
+  fi
+
+  if [ "$OUTPUT_MODE" = "terminal" ]; then
+    echo "  $(_bold "Context Window Impact:")"
+    echo "    $(_dim "Agent tools (Cursor, Windsurf, Copilot) typically use ~200K context")"
+    echo "    $(_dim "regardless of the underlying model's max window.")"
+    echo ""
+    echo "    ├─ Agent context (200K):  ~${ctx_200k_pct}% if all files loaded"
+    echo "    ├─ Agent context (128K):  ~${ctx_128k_pct}% if all files loaded"
+    if [ "$uses_progressive" = true ]; then
+      echo "    ├─ $(_green "SKILL.md only:       ~${skill_only_200k_pct}% of 200K") (initial activation cost)"
+      echo "    └─ $(_dim "Progressive disclosure — references load on demand, not upfront")"
+    else
+      echo "    └─ $(_yellow "Monolithic — entire skill loads at once (no references/ linked)")"
+    fi
+    echo ""
+  fi
+  if [ "$uses_progressive" = true ]; then
+    if [ "$total_tokens" -le "$TOTAL_TOKEN_SAFE" ]; then
+      _pass "Package is well-optimized for token efficiency"
+    elif [ "$total_tokens" -le "$TOTAL_TOKEN_WARN" ]; then
+      _pass "Package size is fine — progressive disclosure loads files on demand"
+    else
+      _info "Large package (~$total_tokens tokens) — ensure no dead/orphaned references"
+      _detail "Total size is informational with progressive disclosure; individual file sizes are the real quality gate"
+    fi
+  else
+    if [ "$total_tokens" -le "$TOKEN_BUDGET" ]; then
+      _pass "Monolithic skill is within token budget"
+    elif [ "$total_tokens" -le "$TOKEN_DANGER_ZONE" ]; then
+      _warn "Monolithic skill (~$total_tokens tokens) exceeds $TOKEN_BUDGET budget"
+      _detail "Move detailed content to references/ for on-demand loading"
+    else
+      _warn "Monolithic skill (~$total_tokens tokens) is very large"
+      _detail "Split into SKILL.md + references/ for progressive disclosure"
+    fi
+  fi
+}
+
+# ── [11] Content Quality Metrics ─────────────────────────────────
+
+check_content_quality() {
+  section "Content Quality Metrics"
+
+  local total_code_blocks=0
+  local total_headings=0
+
+  if [ -f "$SKILL_FILE" ]; then
+    local code_blocks headings internal_links external_links
+    code_blocks=$(safe_count grep -c '```' "$SKILL_FILE")
+    code_blocks=$((code_blocks / 2))
+
+    headings=$(safe_count grep -c '^#' "$SKILL_FILE")
+    internal_links=$(safe_count grep -c '\](references/' "$SKILL_FILE")
+    external_links=$(safe_count grep -cE '\]\(https?://' "$SKILL_FILE")
+
+    echo ""
+    echo "  $(_bold "SKILL.md Structure:")"
+    echo "    ├─ $headings heading(s) providing navigation"
+    echo "    ├─ $code_blocks code example(s)"
+    echo "    ├─ $internal_links internal link(s) to references"
+    echo "    └─ $external_links external link(s)"
+
+    total_code_blocks=$code_blocks
+    total_headings=$headings
+
+    if [ "$headings" -lt 3 ]; then
+      _info "Consider adding more headings for better navigation"
+    fi
+
+    if [ "$code_blocks" -lt 2 ]; then
+      _info "Consider adding code examples for clarity"
+    fi
+  fi
+
+  if [ -d "references" ]; then
+    echo ""
+    echo "  $(_bold "Reference Files Quality:")"
+
+    local ref_with_code=0
+    local ref_without_code=0
+    local total_ref_headings=0
+
+    while IFS= read -r file; do
+      local file_code_blocks file_headings
+      file_code_blocks=$(safe_count grep -c '```' "$file")
+      file_code_blocks=$((file_code_blocks / 2))
+      file_headings=$(safe_count grep -c '^#' "$file")
+
+      total_code_blocks=$((total_code_blocks + file_code_blocks))
+      total_ref_headings=$((total_ref_headings + file_headings))
+
+      if [ "$file_code_blocks" -gt 0 ]; then
+        ref_with_code=$((ref_with_code + 1))
+      else
+        ref_without_code=$((ref_without_code + 1))
+      fi
+    done < <(find references -name '*.md' -type f)
+
+    local ref_count
+    ref_count=$(find references -name '*.md' -type f | wc -l | tr -d ' ')
+
+    echo "    ├─ $ref_with_code of $ref_count files contain code examples"
+    echo "    ├─ $total_ref_headings total headings across references"
+    echo "    └─ $total_code_blocks total code blocks in package"
+
+    if [ "$ref_without_code" -gt 0 ]; then
+      _info "$ref_without_code reference file(s) have no code examples"
+      _detail "Code examples help agents understand expected patterns"
+    fi
+  fi
+
+  # Keyword density analysis
+  if [ -f "$SKILL_FILE" ]; then
+    echo ""
+    echo "  $(_bold "Trigger Keyword Analysis:")"
+
+    local compose_mentions kotlin_mentions android_mentions kmp_mentions
+    compose_mentions=$(grep -ioE '(compose|composable|@Composable|jetpack)' "$SKILL_FILE" 2>/dev/null | wc -l | tr -d ' ' || true)
+    kotlin_mentions=$(grep -ioE '(kotlin|coroutine|flow|stateflow|channel)' "$SKILL_FILE" 2>/dev/null | wc -l | tr -d ' ' || true)
+    android_mentions=$(grep -ioE '(android|viewmodel|hilt|koin|room|datastore)' "$SKILL_FILE" 2>/dev/null | wc -l | tr -d ' ' || true)
+    kmp_mentions=$(grep -ioE '(multiplatform|kmp|cmp|commonmain|ios|desktop)' "$SKILL_FILE" 2>/dev/null | wc -l | tr -d ' ' || true)
+
+    compose_mentions=${compose_mentions:-0}
+    kotlin_mentions=${kotlin_mentions:-0}
+    android_mentions=${android_mentions:-0}
+    kmp_mentions=${kmp_mentions:-0}
+
+    echo "    ├─ Compose/UI:     $compose_mentions mentions"
+    echo "    ├─ Kotlin/Flow:    $kotlin_mentions mentions"
+    echo "    ├─ Android/DI:     $android_mentions mentions"
+    echo "    └─ Multiplatform:  $kmp_mentions mentions"
+
+    local total_keywords=$((compose_mentions + kotlin_mentions + android_mentions + kmp_mentions))
+    if [ "$total_keywords" -lt 10 ]; then
+      _info "Low keyword density may reduce skill trigger accuracy"
+      _detail "Ensure description and body mention key terms agents should recognize"
+    else
+      _pass "Good keyword coverage for skill triggering ($total_keywords mentions)"
+    fi
+  fi
+}
+
+# ── [12] Agent Metadata ──────────────────────────────────────────
+
+check_agents_metadata() {
+  section "Agent Metadata (Codex)"
+
+  if [ ! -f "agents/openai.yaml" ]; then
+    _info "No agents/openai.yaml — optional, configures Codex app UI and policy"
+    return
+  fi
+
+  _pass "agents/openai.yaml present"
+
+  if grep -q '^interface:' agents/openai.yaml; then
+    if grep -q 'display_name:' agents/openai.yaml; then
+      local display_name
+      display_name=$(grep 'display_name:' agents/openai.yaml | sed 's/.*display_name:[[:space:]]*//' | tr -d '"')
+      _pass "display_name: '$display_name'"
+    else
+      _info "No display_name — Codex uses skill name"
+    fi
+
+    if grep -q 'short_description:' agents/openai.yaml; then
+      _pass "short_description set"
+    else
+      _info "No short_description — Codex uses SKILL.md description"
+    fi
+
+    if grep -q 'default_prompt:' agents/openai.yaml; then
+      _pass "default_prompt set"
+    fi
+
+    if grep -q 'brand_color:' agents/openai.yaml; then
+      _pass "brand_color set"
+    fi
+
+    local icon_fields
+    icon_fields=$(grep -oE '(icon_small|icon_large):[[:space:]]*"[^"]+"' agents/openai.yaml 2>/dev/null || true)
+    if [ -n "$icon_fields" ]; then
+      echo "$icon_fields" | while IFS= read -r line; do
+        local field icon_path
+        field=$(echo "$line" | cut -d: -f1 | tr -d ' ')
+        icon_path=$(echo "$line" | sed 's/.*"\(.*\)"/\1/')
+        if [ -f "$icon_path" ]; then
+          _pass "$field: $icon_path"
+        else
+          _warn "$field: '$icon_path' → file not found"
+          ci_annotate "warning" "file=agents/openai.yaml::$field file missing: $icon_path"
+        fi
+      done
+    fi
+  fi
+
+  if grep -q '^policy:' agents/openai.yaml; then
+    if grep -q 'allow_implicit_invocation:' agents/openai.yaml; then
+      local implicit
+      implicit=$(grep 'allow_implicit_invocation:' agents/openai.yaml | sed 's/.*allow_implicit_invocation:[[:space:]]*//')
+      _pass "allow_implicit_invocation: $implicit"
+    fi
+  fi
+
+  if grep -q '^dependencies:' agents/openai.yaml; then
+    _pass "dependencies declared"
+  fi
+}
+
+# ── [13] Security Scan ───────────────────────────────────────────
+
+check_security() {
+  section "Security Scan"
+
+  local secret_count=0
+  local path_count=0
+  local cmd_count=0
+
+  # ── Secrets detection (11 patterns from skill-tools / skill-validator) ──
+  local -a secret_patterns=(
+    'sk-[a-zA-Z0-9]{20,}'
+    'sk_live_[a-zA-Z0-9]+'
+    'sk_test_[a-zA-Z0-9]+'
+    'ghp_[a-zA-Z0-9]{36}'
+    'gho_[a-zA-Z0-9]{36}'
+    'ghu_[a-zA-Z0-9]{36}'
+    'ghs_[a-zA-Z0-9]{36}'
+    'ghr_[a-zA-Z0-9]{36}'
+    'xoxb-[a-zA-Z0-9-]+'
+    'xoxp-[a-zA-Z0-9-]+'
+    'AKIA[0-9A-Z]{16}'
+  )
+
+  while IFS= read -r file; do
+    for pattern in "${secret_patterns[@]}"; do
+      local matches
+      matches=$(grep -nE "$pattern" "$file" 2>/dev/null | head -1 || true)
+      if [ -n "$matches" ]; then
+        secret_count=$((secret_count + 1))
+        local line_num
+        line_num=$(echo "$matches" | cut -d: -f1)
+        _error "$file:$line_num — potential secret/API key detected"
+        _detail "Pattern: $pattern"
+        ci_annotate "error" "file=$file,line=$line_num::Potential secret detected"
+      fi
+    done
+
+    # PEM private key headers
+    local pem_match
+    pem_match=$(grep -n 'BEGIN.*PRIVATE KEY' "$file" 2>/dev/null | head -1 || true)
+    if [ -n "$pem_match" ]; then
+      secret_count=$((secret_count + 1))
+      local line_num
+      line_num=$(echo "$pem_match" | cut -d: -f1)
+      _error "$file:$line_num — private key header detected"
+      ci_annotate "error" "file=$file,line=$line_num::Private key detected"
+    fi
+
+    # JWT tokens
+    local jwt_match
+    jwt_match=$(grep -nE 'eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.' "$file" 2>/dev/null | head -1 || true)
+    if [ -n "$jwt_match" ]; then
+      secret_count=$((secret_count + 1))
+      local line_num
+      line_num=$(echo "$jwt_match" | cut -d: -f1)
+      _error "$file:$line_num — JWT token detected"
+      ci_annotate "error" "file=$file,line=$line_num::JWT token detected"
+    fi
+  done < <(find . -name '*.md' -not -path './.git/*' | sort)
+
+  _S_SECRETS=$secret_count
+
+  if [ "$secret_count" -eq 0 ]; then
+    _pass "No API keys or secrets detected (13 patterns checked)"
+  fi
+
+  # ── Hardcoded absolute paths ──
+  echo ""
+  _detail "Checking for hardcoded paths..."
+
+  while IFS= read -r file; do
+    local path_matches
+    path_matches=$(grep -nE '(/Users/[a-zA-Z]|/home/[a-zA-Z]|C:\\Users\\)' "$file" 2>/dev/null || true)
+    if [ -n "$path_matches" ]; then
+      while IFS= read -r match; do
+        path_count=$((path_count + 1))
+        local line_num
+        line_num=$(echo "$match" | cut -d: -f1)
+        _warn "$file:$line_num — hardcoded absolute path"
+        _detail "$(echo "$match" | cut -d: -f2- | sed 's/^[[:space:]]*//' | head -c 80)"
+      done <<< "$path_matches"
+    fi
+  done < <(find . -name '*.md' -not -path './.git/*' | sort)
+
+  _S_HARDCODED=$path_count
+
+  if [ "$path_count" -eq 0 ]; then
+    _pass "No hardcoded absolute paths"
+  fi
+
+  # ── Dangerous commands ──
+  echo ""
+  _detail "Checking for dangerous commands..."
+
+  local -a dangerous_patterns=(
+    'rm -rf /'
+    'sudo rm '
+    'DROP TABLE'
+    'DROP DATABASE'
+    'chmod 777'
+  )
+  local -a dangerous_regex=(
+    'curl .*\| *sh'
+    'wget .*\| *sh'
+  )
+
+  while IFS= read -r file; do
+    for pattern in "${dangerous_patterns[@]}"; do
+      local matches
+      matches=$(grep -nF "$pattern" "$file" 2>/dev/null | head -1 || true)
+      if [ -n "$matches" ]; then
+        cmd_count=$((cmd_count + 1))
+        local line_num
+        line_num=$(echo "$matches" | cut -d: -f1)
+        _warn "$file:$line_num — potentially dangerous command"
+        _detail "Matched: $pattern"
+        ci_annotate "warning" "file=$file,line=$line_num::Dangerous command pattern"
+      fi
+    done
+    for pattern in "${dangerous_regex[@]}"; do
+      local matches
+      matches=$(grep -nE "$pattern" "$file" 2>/dev/null | head -1 || true)
+      if [ -n "$matches" ]; then
+        cmd_count=$((cmd_count + 1))
+        local line_num
+        line_num=$(echo "$matches" | cut -d: -f1)
+        _warn "$file:$line_num — potentially dangerous command"
+        _detail "Matched: $pattern"
+        ci_annotate "warning" "file=$file,line=$line_num::Dangerous command pattern"
+      fi
+    done
+  done < <(find . -name '*.md' -not -path './.git/*' | sort)
+
+  _S_DANGEROUS=$cmd_count
+
+  if [ "$cmd_count" -eq 0 ]; then
+    _pass "No dangerous shell commands detected"
+  fi
+
+  echo ""
+  echo "  $(_dim "  ┌─────────────────────────────────────────────┐")"
+  printf "  $(_dim "  │") Secrets: %-4s  Paths: %-4s  Commands: %-4s $(_dim "│")\n" "$secret_count" "$path_count" "$cmd_count"
+  echo "  $(_dim "  └─────────────────────────────────────────────┘")"
+}
+
+# ── [14] Heading Hierarchy & Duplicates ──────────────────────────
+
+check_heading_hierarchy() {
+  section "Heading Hierarchy & Duplicates"
+
+  local skip_issues=0
+  local dup_issues=0
+  local files_checked=0
+
+  while IFS= read -r file; do
+    files_checked=$((files_checked + 1))
+
+    local prev_level=0
+    local line_num=0
+    local headings_seen=""
+
+    while IFS= read -r line; do
+      line_num=$((line_num + 1))
+      case "$line" in
+        '#'*)
+          local stripped_prefix
+          stripped_prefix="${line%%[^#]*}"
+          local level=${#stripped_prefix}
+          local text
+          text=$(echo "$line" | sed 's/^#* *//')
+
+          # MD001: heading level increment check
+          if [ "$prev_level" -gt 0 ] && [ "$level" -gt "$((prev_level + 1))" ]; then
+            skip_issues=$((skip_issues + 1))
+            _warn "$file:$line_num — heading skip H$prev_level → H$level"
+            _detail "$text"
+            ci_annotate "warning" "file=$file,line=$line_num::Heading level skip (H$prev_level to H$level)"
+          fi
+          prev_level=$level
+
+          # MD024: duplicate heading check (same level, same text)
+          local key="${level}:${text}"
+          if echo "$headings_seen" | grep -qF "<<${key}>>"; then
+            dup_issues=$((dup_issues + 1))
+            _warn "$file:$line_num — duplicate heading: '$text' (H$level)"
+            ci_annotate "warning" "file=$file,line=$line_num::Duplicate heading '$text'"
+          fi
+          headings_seen="${headings_seen}<<${key}>>"
+          ;;
+      esac
+    done < "$file"
+  done < <(find . -name '*.md' -not -path './.git/*' | sort)
+
+  if [ "$skip_issues" -eq 0 ]; then
+    _pass "No heading level skips across $files_checked files (MD001)"
+  fi
+
+  if [ "$dup_issues" -eq 0 ]; then
+    _pass "No duplicate sibling headings across $files_checked files (MD024)"
+  fi
+
+  echo ""
+  _detail "Checked $files_checked files | Level skips: $skip_issues | Duplicates: $dup_issues"
+}
+
+# ── [15] Quality Score ───────────────────────────────────────────
+
+compute_quality_score() {
+  section "Quality Score"
+
+  local desc_score=0
+  local clarity_score=0
+  local spec_score=0
+  local progressive_score=0
+  local security_score=10
+
+  _check_mark() {
+    if [ "$1" = "1" ]; then printf "%s" "$(_green "✓")"; else printf "%s" "$(_red "✗")"; fi
+  }
+
+  # ── Description Quality (30 pts) ──
+  local d1=0 d2=0 d3=0 d4=0 d5=0 d6=0 d7=0
+  [ "$_S_HAS_DESC" = true ]              && { desc_score=$((desc_score + 5)); d1=1; }
+  [ "$_S_DESC_LEN" -ge "$DESC_MIN_USEFUL" ] && { desc_score=$((desc_score + 3)); d2=1; }
+  [ "$_S_DESC_WHAT" = true ]             && { desc_score=$((desc_score + 5)); d3=1; }
+  [ "$_S_DESC_WHEN" = true ]             && { desc_score=$((desc_score + 5)); d4=1; }
+  [ "$_S_DESC_FIRST_PERSON" = false ]    && { desc_score=$((desc_score + 4)); d5=1; }
+  [ "$_S_DESC_GENERIC" = false ]         && { desc_score=$((desc_score + 4)); d6=1; }
+  [ "$_S_DESC_NEGATIVES" = true ]        && { desc_score=$((desc_score + 4)); d7=1; }
+
+  # ── Instruction Clarity (25 pts) ──
+  local c1=0 c2=0 c3=0 c4=0 c5=0 c6=0
+  [ "$_S_CODE_BLOCKS" -ge 1 ] && { clarity_score=$((clarity_score + 5)); c1=1; }
+  [ "$_S_CODE_BLOCKS" -ge 3 ] && { clarity_score=$((clarity_score + 5)); c2=1; }
+  [ "$_S_HEADINGS" -ge 3 ]    && { clarity_score=$((clarity_score + 5)); c3=1; }
+  [ "$_S_HEADINGS" -ge 5 ]    && { clarity_score=$((clarity_score + 3)); c4=1; }
+  [ "$_S_REF_LINKS" -gt 0 ]   && { clarity_score=$((clarity_score + 4)); c5=1; }
+  [ "$_S_REF_LINKS" -ge 5 ]   && { clarity_score=$((clarity_score + 3)); c6=1; }
+
+  # ── Spec Compliance (20 pts) ──
+  local s1=0 s2=0 s3=0 s4=0 s5=0 s6=0
+  [ "$_S_HAS_NAME" = true ]                  && { spec_score=$((spec_score + 4)); s1=1; }
+  [ "$_S_NAME_OK" = true ]                   && { spec_score=$((spec_score + 4)); s2=1; }
+  [ "$_S_HAS_DESC" = true ]                  && { spec_score=$((spec_score + 4)); s3=1; }
+  [ "$_S_BODY_TOKENS" -le "$TOKEN_BUDGET" ]  && { spec_score=$((spec_score + 4)); s4=1; }
+  [ "$_S_BODY_LINES" -le "$BODY_MAX_LINES" ] && { spec_score=$((spec_score + 2)); s5=1; }
+  [ "$_S_FENCES_OK" = true ]                 && { spec_score=$((spec_score + 2)); s6=1; }
+
+  # ── Progressive Disclosure (15 pts) ──
+  local p1=0 p2=0 p3=0 p4=0
+  [ "$_S_HAS_REFS_DIR" = true ] && { progressive_score=$((progressive_score + 5)); p1=1; }
+  [ "$_S_REFS_LINKED" -gt 0 ]  && { progressive_score=$((progressive_score + 5)); p2=1; }
+  if [ "$_S_REFS_TOTAL" -gt 0 ] && [ "$_S_REFS_LINKED" -eq "$_S_REFS_TOTAL" ]; then
+    progressive_score=$((progressive_score + 3)); p3=1
+  fi
+  [ "$_S_BODY_LINES" -lt "$BODY_MAX_LINES" ] && { progressive_score=$((progressive_score + 2)); p4=1; }
+
+  # ── Security (10 pts — start at 10, deduct for issues) ──
+  local x1=1 x2=1 x3=1
+  if [ "$_S_SECRETS" -gt 0 ]; then security_score=$((security_score - 5)); x1=0; fi
+  if [ "$_S_HARDCODED" -gt 0 ]; then security_score=$((security_score - 3)); x2=0; fi
+  if [ "$_S_DANGEROUS" -gt 0 ]; then security_score=$((security_score - 2)); x3=0; fi
+  [ "$security_score" -lt 0 ] && security_score=0
+
+  local total_score=$((desc_score + clarity_score + spec_score + progressive_score + security_score))
+
+  QUALITY_SCORE=$total_score
+  QUALITY_GRADE=$(letter_grade "$total_score")
+  QUALITY_DESC=$desc_score
+  QUALITY_CLARITY=$clarity_score
+  QUALITY_SPEC=$spec_score
+  QUALITY_PROGRESSIVE=$progressive_score
+  QUALITY_SECURITY=$security_score
+
+  # ── Display: clean bar chart overview (terminal only) ──
+  if [ "$OUTPUT_MODE" = "terminal" ]; then
+
+  _dim_bar() {
+    local name="$1" score="$2" max="$3"
+    local gauge pct
+    if [ "$max" -le 0 ]; then pct=0
+    else pct=$(( (score * 100) / max )); fi
+    gauge=$(bar_gauge "$score" "$max" 15)
+
+    local colored_gauge
+    if [ "$pct" -ge 80 ]; then
+      colored_gauge=$(_green "$gauge")
+    elif [ "$pct" -ge 60 ]; then
+      colored_gauge=$(_yellow "$gauge")
+    else
+      colored_gauge=$(_red "$gauge")
+    fi
+
+    if [ "$score" -eq "$max" ]; then
+      printf "    %-24s %s  %2d / %-2d  $(_green "✓")\n" "$name" "$colored_gauge" "$score" "$max"
+    else
+      printf "    %-24s %s  %2d / %-2d\n" "$name" "$colored_gauge" "$score" "$max"
+    fi
+  }
+
+  echo ""
+  echo "  $(_bold "Score Overview:")"
+  echo ""
+  _dim_bar "Description Quality" "$desc_score" "30"
+  _dim_bar "Instruction Clarity" "$clarity_score" "25"
+  _dim_bar "Spec Compliance" "$spec_score" "20"
+  _dim_bar "Progressive Disclosure" "$progressive_score" "15"
+  _dim_bar "Security" "$security_score" "10"
+
+  echo ""
+  echo "    $(_dim "$(printf '%.0s─' $(seq 1 52))")"
+
+  local total_gauge
+  total_gauge=$(bar_gauge "$total_score" 100 20)
+  local colored_total
+  if [ "$total_score" -ge 85 ]; then
+    colored_total=$(_bold_green "$total_gauge")
+  elif [ "$total_score" -ge 65 ]; then
+    colored_total=$(_bold_yellow "$total_gauge")
+  else
+    colored_total=$(_bold_red "$total_gauge")
+  fi
+
+  printf "    %-24s %s  %d/100   Grade: %s\n" "$(_bold "TOTAL")" "$colored_total" "$total_score" "$(grade_color "$total_score")"
+
+  fi  # end terminal-only display
+
+  # ── Collect failed sub-checks with sources and actionable fixes ──
+  # Sources verified against:
+  #   [spec]       agentskills.io/specification
+  #   [official]   agentskills.io/skill-creation/best-practices
+  #                agentskills.io/skill-creation/optimizing-descriptions
+  #   [community]  mdskills.ai/docs/skill-best-practices
+
+  local fix_count=0
+  local -a fix_labels=()
+  local -a fix_sources=()
+  local -a fix_dims=()
+
+  [ "$d1" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Add a 'description:' field to frontmatter (+5)"); fix_sources+=("spec: required field — agentskills.io/specification#description-field"); }
+  [ "$d2" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Write a longer description (>=${DESC_MIN_USEFUL} chars) (+3)"); fix_sources+=("official: 'A few sentences to a short paragraph' — agentskills.io/skill-creation/optimizing-descriptions"); }
+  [ "$d3" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Add action verbs: build, generate, validate, extract, etc. (+5)"); fix_sources+=("spec: 'Describes what the skill does' — agentskills.io/specification#description-field"); }
+  [ "$d4" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Add 'Use when...' trigger phrase to description (+5)"); fix_sources+=("official: 'Use imperative phrasing: Use this skill when...' — agentskills.io/skill-creation/optimizing-descriptions"); }
+  [ "$d5" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Rewrite in third person — remove 'I can/will/help' (+4)"); fix_sources+=("community: 'first person causes discovery problems' — mdskills.ai/docs/skill-best-practices"); }
+  [ "$d6" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Replace generic verbs (manage, handle) with specific ones (+4)"); fix_sources+=("community: 'Be specific about format, operation, trigger' — mdskills.ai/docs/skill-best-practices"); }
+  [ "$d7" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Add boundary markers: 'Not for...', 'Do not use when...' (+4)"); fix_sources+=("official: 'Add specificity about what the skill does not do' — agentskills.io/skill-creation/optimizing-descriptions"); }
+  [ "$c1" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Clarity"); fix_labels+=("Add at least 1 fenced code block to SKILL.md body (+5)"); fix_sources+=("spec: recommended section 'Examples of inputs and outputs' — agentskills.io/specification#body-content"); }
+  [ "$c2" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Clarity"); fix_labels+=("Add 3+ code examples to SKILL.md (+5)"); fix_sources+=("official: 'a working example tends to outperform exhaustive documentation' — agentskills.io/skill-creation/best-practices"); }
+  [ "$c3" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Clarity"); fix_labels+=("Add 3+ markdown headings for navigation (+5)"); fix_sources+=("community: 'structure for agent scanning' — mdskills.ai/docs/skill-best-practices"); }
+  [ "$c4" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Clarity"); fix_labels+=("Add 5+ headings for deeper structure (+3)"); fix_sources+=("community: structured content aids agent navigation — mdskills.ai/docs/skill-best-practices"); }
+  [ "$c5" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Clarity"); fix_labels+=("Link to at least one reference file from SKILL.md body (+4)"); fix_sources+=("official: 'tell the agent when to load each file' — agentskills.io/skill-creation/best-practices"); }
+  [ "$c6" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Clarity"); fix_labels+=("Link to 5+ reference files for full coverage (+3)"); fix_sources+=("official: progressive disclosure via on-demand loading — agentskills.io/specification#progressive-disclosure"); }
+  [ "$s4" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Spec"); fix_labels+=("Reduce SKILL.md to <$TOKEN_BUDGET tokens — move content to references/ (+4)"); fix_sources+=("spec: '<5000 tokens recommended' — agentskills.io/specification#progressive-disclosure"); }
+  [ "$s5" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Spec"); fix_labels+=("Reduce SKILL.md body to <$BODY_MAX_LINES lines (+2)"); fix_sources+=("spec: 'under 500 lines' — agentskills.io/specification#progressive-disclosure"); }
+  [ "$s6" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Spec"); fix_labels+=("Fix unclosed code fences in markdown files (+2)"); fix_sources+=("spec: valid markdown required for agent parsing"); }
+  [ "$p1" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Disclosure"); fix_labels+=("Create a references/ directory for detailed docs (+5)"); fix_sources+=("spec: optional directory for on-demand loading — agentskills.io/specification#references"); }
+  [ "$p2" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Disclosure"); fix_labels+=("Add links to reference files from SKILL.md body (+5)"); fix_sources+=("official: 'tell the agent when to load each file' — agentskills.io/skill-creation/best-practices"); }
+  [ "$p3" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Disclosure"); fix_labels+=("Link all reference files from SKILL.md — orphaned files found (+3)"); fix_sources+=("spec: 'use relative paths from skill root' — agentskills.io/specification#file-references"); }
+  [ "$p4" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Disclosure"); fix_labels+=("Keep body under $BODY_MAX_LINES lines (+2)"); fix_sources+=("spec: 'under 500 lines' — agentskills.io/specification#progressive-disclosure"); }
+  [ "$x1" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Security"); fix_labels+=("Remove detected API keys/secrets from markdown files (+5)"); fix_sources+=("community: 'Never hardcode credentials' — mdskills.ai/docs/skill-best-practices"); }
+  [ "$x2" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Security"); fix_labels+=("Replace hardcoded paths (/Users/...) with relative paths (+3)"); fix_sources+=("spec: 'use relative paths from the skill root' — agentskills.io/specification#file-references"); }
+  [ "$x3" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Security"); fix_labels+=("Remove or guard dangerous commands (rm -rf, chmod 777) (+2)"); fix_sources+=("community: 'Shell commands need guardrails' — mdskills.ai/docs/skill-best-practices"); }
+
+  # ── Display: "How to Improve" section with sourced fixes (terminal only) ──
+  if [ "$OUTPUT_MODE" = "terminal" ]; then
+    if [ "$fix_count" -gt 0 ]; then
+      echo ""
+      echo ""
+      echo "  $(_bold "┌────────────────────────────────────────────────────────┐")"
+      echo "  $(_bold "│")  $(_bold "HOW TO REACH 100/100")  $(_dim "($fix_count items, by impact)")          $(_bold "│")"
+      echo "  $(_bold "└────────────────────────────────────────────────────────┘")"
+
+      local prev_dim=""
+      local i
+      for ((i=0; i<fix_count; i++)); do
+        local dim="${fix_dims[$i]}"
+        local label="${fix_labels[$i]}"
+        local source="${fix_sources[$i]}"
+
+        if [ "$dim" != "$prev_dim" ]; then
+          echo ""
+          echo "  $(_bold_cyan "$dim:")"
+          prev_dim="$dim"
+        fi
+
+        echo "    $(_yellow "→") $label"
+        echo "      $(_dim "$source")"
+      done
+
+      echo ""
+      echo "  $(_dim "Sources: spec = agentskills.io/specification")"
+      echo "  $(_dim "         official = agentskills.io/skill-creation/*")"
+      echo "  $(_dim "         community = mdskills.ai/docs/skill-best-practices")"
+    else
+      echo ""
+      _pass "Perfect score — no improvements needed"
+    fi
+  fi
+}
+
+# ════════════════════════════════════════════════════════════════════
+#  OUTPUT MODES
+# ════════════════════════════════════════════════════════════════════
+
+print_json() {
+  local elapsed=$(( SECONDS - SCAN_START ))
+
+  # Collect findings into JSON arrays
+  local errors_json="[]" warnings_json="[]" info_json="[]"
+
+  local err_items=""
+  while IFS='|' read -r sev sec msg; do
+    [ -z "$sev" ] && continue
+    msg=$(echo "$msg" | sed 's/"/\\"/g')
+    sec=$(echo "$sec" | sed 's/"/\\"/g')
+    case "$sev" in
+      ERROR) err_items="${err_items}{\"section\":\"$sec\",\"message\":\"$msg\"}," ;;
+    esac
+  done < "$FINDINGS_FILE"
+
+  local warn_items=""
+  while IFS='|' read -r sev sec msg; do
+    [ -z "$sev" ] && continue
+    msg=$(echo "$msg" | sed 's/"/\\"/g')
+    sec=$(echo "$sec" | sed 's/"/\\"/g')
+    case "$sev" in
+      WARN) warn_items="${warn_items}{\"section\":\"$sec\",\"message\":\"$msg\"}," ;;
+    esac
+  done < "$FINDINGS_FILE"
+
+  local info_items=""
+  while IFS='|' read -r sev sec msg; do
+    [ -z "$sev" ] && continue
+    msg=$(echo "$msg" | sed 's/"/\\"/g')
+    sec=$(echo "$sec" | sed 's/"/\\"/g')
+    case "$sev" in
+      INFO) info_items="${info_items}{\"section\":\"$sec\",\"message\":\"$msg\"}," ;;
+    esac
+  done < "$FINDINGS_FILE"
+
+  # Strip trailing commas and wrap
+  err_items="${err_items%,}"
+  warn_items="${warn_items%,}"
+  info_items="${info_items%,}"
+  [ -n "$err_items" ] && errors_json="[$err_items]"
+  [ -n "$warn_items" ] && warnings_json="[$warn_items]"
+  [ -n "$info_items" ] && info_json="[$info_items]"
+
+  local result="PASS"
+  [ "$WARNINGS" -gt 0 ] && result="PASS_WITH_WARNINGS"
+  [ "$ERRORS" -gt 0 ] && result="FAIL"
+
+  cat <<ENDJSON
+{
+  "version": "4.0",
+  "skill": "$(pwd)",
+  "timestamp": "$(date -u '+%Y-%m-%dT%H:%M:%SZ')",
+  "duration_seconds": $elapsed,
+  "result": "$result",
+  "counts": {
+    "checks": $CHECKS_RUN,
+    "errors": $ERRORS,
+    "warnings": $WARNINGS,
+    "info": $INFO_COUNT
+  },
+  "quality_score": {
+    "total": $QUALITY_SCORE,
+    "grade": "$QUALITY_GRADE",
+    "dimensions": {
+      "description_quality": { "score": $QUALITY_DESC, "max": 30 },
+      "instruction_clarity": { "score": $QUALITY_CLARITY, "max": 25 },
+      "spec_compliance": { "score": $QUALITY_SPEC, "max": 20 },
+      "progressive_disclosure": { "score": $QUALITY_PROGRESSIVE, "max": 15 },
+      "security": { "score": $QUALITY_SECURITY, "max": 10 }
+    }
+  },
+  "token_analysis": {
+    "skill_md_tokens": $_S_BODY_TOKENS,
+    "body_lines": $_S_BODY_LINES,
+    "code_blocks": $_S_CODE_BLOCKS,
+    "headings": $_S_HEADINGS,
+    "reference_links": $_S_REF_LINKS,
+    "reference_files_total": $_S_REFS_TOTAL,
+    "reference_files_linked": $_S_REFS_LINKED
+  },
+  "findings": {
+    "errors": $errors_json,
+    "warnings": $warnings_json,
+    "info": $info_json
+  }
+}
+ENDJSON
+}
+
+print_markdown() {
+  local elapsed=$(( SECONDS - SCAN_START ))
+
+  local result_icon result_text
+  if [ "$ERRORS" -gt 0 ]; then
+    result_icon="x" result_text="FAIL — $ERRORS error(s), $WARNINGS warning(s)"
+  elif [ "$WARNINGS" -gt 0 ]; then
+    result_icon="!" result_text="PASS with $WARNINGS warning(s)"
+  else
+    result_icon="+" result_text="All checks passed"
+  fi
+
+  cat <<ENDMD
+# Agent Skill Scan Report
+
+**Score: $QUALITY_SCORE/100 ($QUALITY_GRADE)** | Result: $result_text | Duration: ${elapsed}s
+
+## Score Breakdown
+
+| Dimension | Score | Max | % |
+|---|---|---|---|
+| Description Quality | $QUALITY_DESC | 30 | $(( (QUALITY_DESC * 100) / 30 ))% |
+| Instruction Clarity | $QUALITY_CLARITY | 25 | $(( (QUALITY_CLARITY * 100) / 25 ))% |
+| Spec Compliance | $QUALITY_SPEC | 20 | $(( (QUALITY_SPEC * 100) / 20 ))% |
+| Progressive Disclosure | $QUALITY_PROGRESSIVE | 15 | $(( (QUALITY_PROGRESSIVE * 100) / 15 ))% |
+| Security | $QUALITY_SECURITY | 10 | $(( (QUALITY_SECURITY * 100) / 10 ))% |
+| **Total** | **$QUALITY_SCORE** | **100** | **${QUALITY_SCORE}%** |
+
+## Summary
+
+| Metric | Count |
+|---|---|
+| Sections checked | $CHECKS_RUN |
+| Errors | $ERRORS |
+| Warnings | $WARNINGS |
+| Suggestions | $INFO_COUNT |
+
+## Token Analysis
+
+| File | Tokens | Lines |
+|---|---|---|
+| SKILL.md | ~$_S_BODY_TOKENS | $_S_BODY_LINES |
+| Reference files | $_S_REFS_TOTAL files | $_S_REFS_LINKED linked |
+ENDMD
+
+  # Findings
+  local total_findings
+  total_findings=$(wc -l < "$FINDINGS_FILE" | tr -d ' ')
+
+  if [ "$total_findings" -gt 0 ]; then
+    echo ""
+    echo "## Findings"
+    echo ""
+
+    local has_errors has_warnings has_info
+    has_errors=$(grep -c "^ERROR|" "$FINDINGS_FILE" 2>/dev/null || true)
+    has_warnings=$(grep -c "^WARN|" "$FINDINGS_FILE" 2>/dev/null || true)
+    has_info=$(grep -c "^INFO|" "$FINDINGS_FILE" 2>/dev/null || true)
+
+    if [ "${has_errors:-0}" -gt 0 ]; then
+      echo "### Errors"
+      echo ""
+      grep "^ERROR|" "$FINDINGS_FILE" | while IFS='|' read -r _ sec msg; do
+        echo "- **$sec**: $msg"
+      done
+      echo ""
+    fi
+
+    if [ "${has_warnings:-0}" -gt 0 ]; then
+      echo "### Warnings"
+      echo ""
+      grep "^WARN|" "$FINDINGS_FILE" | while IFS='|' read -r _ sec msg; do
+        echo "- **$sec**: $msg"
+      done
+      echo ""
+    fi
+
+    if [ "${has_info:-0}" -gt 0 ]; then
+      echo "### Suggestions"
+      echo ""
+      grep "^INFO|" "$FINDINGS_FILE" | while IFS='|' read -r _ sec msg; do
+        echo "- **$sec**: $msg"
+      done
+      echo ""
+    fi
+  fi
+
+  echo "---"
+  echo ""
+  echo "_Validated against [agentskills.io/specification](https://agentskills.io/specification) | Scanner v4.0 | $(date '+%Y-%m-%d %H:%M:%S')_"
+}
+
+print_score_only() {
+  if [ "$NO_COLOR" = "1" ]; then
+    echo "$QUALITY_SCORE/100 $QUALITY_GRADE"
+  else
+    echo "$QUALITY_SCORE/100 $(grade_color "$QUALITY_SCORE")"
+  fi
+  [ "$ERRORS" -gt 0 ] && exit 1
+  exit 0
+}
+
+# ════════════════════════════════════════════════════════════════════
+#  REPORT (terminal)
+# ════════════════════════════════════════════════════════════════════
+
+print_report() {
+  echo ""
+  echo ""
+
+  local elapsed=$(( SECONDS - SCAN_START ))
+
+  _pad() {
+    local text="$1" width="$2"
+    local visible
+    visible=$(echo "$text" | sed 's/\x1b\[[0-9;]*m//g')
+    local pad_len=$(( width - ${#visible} ))
+    if [ "$pad_len" -lt 0 ]; then pad_len=0; fi
+    printf '%s%*s' "$text" "$pad_len" ""
+  }
+
+  local W=58
+  local line
+  line=$(printf '%.0s═' $(seq 1 $W))
+  local thin_line
+  thin_line=$(printf '%.0s─' $(seq 1 $W))
+
+  # ── Summary box ──
+  echo "  $(_bold "╔${line}╗")"
+  echo "  $(_bold "║")$(_pad "" $W)$(_bold "║")"
+  echo "  $(_bold "║")$(_pad "          AGENT SKILL SCAN REPORT                       " $W)$(_bold "║")"
+  echo "  $(_bold "║")$(_pad "                Scanner v4.0                             " $W)$(_bold "║")"
+  echo "  $(_bold "║")$(_pad "" $W)$(_bold "║")"
+  echo "  $(_bold "╠${line}╣")"
+  echo "  ║$(_pad "" $W)║"
+  echo "  ║  $(_pad "Checks run     :  $CHECKS_RUN sections" $(( W - 2 )))║"
+  echo "  ║  $(_pad "$(_red "Errors")         :  $ERRORS" $(( W - 2 )))║"
+  echo "  ║  $(_pad "$(_yellow "Warnings")       :  $WARNINGS" $(( W - 2 )))║"
+  echo "  ║  $(_pad "$(_blue "Info")           :  $INFO_COUNT" $(( W - 2 )))║"
+  echo "  ║  $(_pad "Duration       :  ${elapsed}s" $(( W - 2 )))║"
+  echo "  ║$(_pad "" $W)║"
+  echo "  $(_bold "╠${line}╣")"
+  echo "  ║$(_pad "" $W)║"
+
+  local result_text
+  if [ "$ERRORS" -gt 0 ]; then
+    result_text=$(_bold_red "FAIL")
+  elif [ "$WARNINGS" -gt 0 ]; then
+    result_text=$(_bold_yellow "PASS with warnings")
+  else
+    result_text=$(_bold_green "ALL CLEAR")
+  fi
+
+  echo "  ║  $(_pad "Result         :  $result_text" $(( W - 2 )))║"
+  echo "  ║  $(_pad "Quality Score  :  $QUALITY_SCORE/100  ($(grade_color "$QUALITY_SCORE"))" $(( W - 2 )))║"
+  echo "  ║$(_pad "" $W)║"
+  echo "  $(_bold "╠${line}╣")"
+  echo "  ║$(_pad "" $W)║"
+  echo "  ║  $(_pad "Description    :  $QUALITY_DESC/30" $(( W - 2 )))║"
+  echo "  ║  $(_pad "Clarity        :  $QUALITY_CLARITY/25" $(( W - 2 )))║"
+  echo "  ║  $(_pad "Spec           :  $QUALITY_SPEC/20" $(( W - 2 )))║"
+  echo "  ║  $(_pad "Progressive    :  $QUALITY_PROGRESSIVE/15" $(( W - 2 )))║"
+  echo "  ║  $(_pad "Security       :  $QUALITY_SECURITY/10" $(( W - 2 )))║"
+  echo "  ║$(_pad "" $W)║"
+  echo "  $(_bold "╚${line}╝")"
+
+  # ── Detailed findings ──
+  local total_findings
+  total_findings=$(wc -l < "$FINDINGS_FILE" | tr -d ' ')
+
+  if [ "$total_findings" -gt 0 ]; then
+    echo ""
+    echo "  $(_bold "┌${thin_line}┐")"
+    echo "  $(_bold "│")$(_pad "                  DETAILED FINDINGS                       " $W)$(_bold "│")"
+    echo "  $(_bold "└${thin_line}┘")"
+
+    _print_findings_by_severity() {
+      local severity="$1" label="$2" color_fn="$3"
+      local matches
+      matches=$(grep "^${severity}|" "$FINDINGS_FILE" 2>/dev/null || true)
+      [ -z "$matches" ] && return
+
+      local count
+      count=$(echo "$matches" | wc -l | tr -d ' ')
+
+      echo ""
+      echo "  $($color_fn "$label ($count)")"
+      echo "  $(_dim "$(printf '%.0s─' $(seq 1 56))")"
+
+      local prev_section=""
+      while IFS='|' read -r _ sec msg; do
+        if [ "$sec" != "$prev_section" ]; then
+          echo ""
+          echo "  $(_dim "[$sec]")"
+          prev_section="$sec"
+        fi
+        echo "    $($color_fn "▸") $msg"
+      done <<< "$matches"
+    }
+
+    _print_findings_by_severity "ERROR" "ERRORS" "_red"
+    _print_findings_by_severity "WARN"  "WARNINGS" "_yellow"
+    _print_findings_by_severity "INFO"  "SUGGESTIONS" "_blue"
+
+    echo ""
+    echo "  $(_dim "$(printf '%.0s─' $(seq 1 60))")"
+  else
+    echo ""
+    echo "  $(_bold_green "No findings — skill package is in perfect shape.")"
+  fi
+
+  echo ""
+  echo "  $(_dim "Validated against agentskills.io/specification")"
+  echo "  $(_dim "Scored using skill-tools 5-dimension rubric (0-100)")"
+  echo ""
+
+  # ── CI job summary ──
+  if [ "$CI" = "true" ]; then
+    {
+      echo "## Agent Skill Scan Report"
+      echo ""
+      if [ "$ERRORS" -gt 0 ]; then
+        echo "**FAIL** — $ERRORS error(s), $WARNINGS warning(s)"
+      elif [ "$WARNINGS" -gt 0 ]; then
+        echo "**PASS with $WARNINGS warning(s)**"
+      else
+        echo "**All checks passed**"
+      fi
+      echo ""
+      echo "**Quality Score: $QUALITY_SCORE/100 (Grade: $QUALITY_GRADE)**"
+      echo ""
+      echo "| Metric | Count |"
+      echo "|--------|-------|"
+      echo "| Checks | $CHECKS_RUN |"
+      echo "| Errors | $ERRORS |"
+      echo "| Warnings | $WARNINGS |"
+      echo "| Info | $INFO_COUNT |"
+      echo "| Duration | ${elapsed}s |"
+      echo ""
+      echo "| Dimension | Score |"
+      echo "|-----------|-------|"
+      echo "| Description Quality | $QUALITY_DESC/30 |"
+      echo "| Instruction Clarity | $QUALITY_CLARITY/25 |"
+      echo "| Spec Compliance | $QUALITY_SPEC/20 |"
+      echo "| Progressive Disclosure | $QUALITY_PROGRESSIVE/15 |"
+      echo "| Security | $QUALITY_SECURITY/10 |"
+      echo "| **Total** | **$QUALITY_SCORE/100 ($QUALITY_GRADE)** |"
+
+      if [ "$total_findings" -gt 0 ]; then
+        echo ""
+        echo "### Findings"
+        echo ""
+        echo "<details>"
+        echo "<summary>Click to expand ($total_findings findings)</summary>"
+        echo ""
+
+        _ci_findings() {
+          local severity="$1" icon="$2"
+          local matches
+          matches=$(grep "^${severity}|" "$FINDINGS_FILE" 2>/dev/null || true)
+          [ -z "$matches" ] && return
+
+          while IFS='|' read -r _ sec msg; do
+            echo "- ${icon} **${sec}**: ${msg}"
+          done <<< "$matches"
+        }
+
+        _ci_findings "ERROR" "x"
+        _ci_findings "WARN"  "!"
+        _ci_findings "INFO"  "i"
+
+        echo ""
+        echo "</details>"
+      fi
+
+      echo ""
+      echo "_Validated against [agentskills.io/specification](https://agentskills.io/specification) | Score: $QUALITY_SCORE/100 ($QUALITY_GRADE)_"
+    } >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
+  fi
+}
+
+# ════════════════════════════════════════════════════════════════════
+#  MAIN
+# ════════════════════════════════════════════════════════════════════
+
+show_help() {
+  echo ""
+  echo "  $(_bold "Agent Skill Scanner v4")"
+  echo ""
+  echo "  Validates skill packages against the agentskills.io specification"
+  echo "  and community best practices for AI agent skills."
+  echo "  Computes a quality score (0-100) across 5 dimensions."
+  echo ""
+  echo "  $(_bold "Usage:")"
+  echo "    ./scripts/validate.sh                Run full scan (terminal)"
+  echo "    ./scripts/validate.sh --json        Output as JSON"
+  echo "    ./scripts/validate.sh --md          Output as Markdown report"
+  echo "    ./scripts/validate.sh --score-only  Print score and grade only"
+  echo "    ./scripts/validate.sh --help        Show this help"
+  echo ""
+  echo "  $(_bold "Output Modes:")"
+  echo "    $(_cyan "--json")         Machine-readable JSON (pipe to jq, feed to web tools)"
+  echo "    $(_cyan "--md")           Markdown report (save to file, paste in PRs/docs)"
+  echo "    $(_cyan "--score-only")   Quick score check (e.g., in pre-commit hooks)"
+  echo "    $(_dim "(default)")      Rich terminal output with colors and bar charts"
+  echo ""
+  echo "  $(_bold "Environment:")"
+  echo "    CI=true      Emit GitHub Actions annotations + job summary"
+  echo "    NO_COLOR=1   Disable colored output"
+  echo ""
+  echo "  $(_bold "Checks (15 sections, 20+ individual checks):")"
+  echo "    $(_cyan " 1.") Skill structure        Required/optional files and directories"
+  echo "    $(_cyan " 2.") Frontmatter & desc     name, description, voice, verbs, negative triggers"
+  echo "    $(_cyan " 3.") Body & disclosure      Line count, token budget, structure, code examples"
+  echo "    $(_cyan " 4.") Internal links         All links resolve to existing files"
+  echo "    $(_cyan " 5.") Reference files        Linked, orphaned, empty, and missing detection"
+  echo "    $(_cyan " 6.") Markdown syntax        Unclosed code blocks"
+  echo "    $(_cyan " 7.") Reference nesting      No deep cross-reference chains"
+  echo "    $(_cyan " 8.") Scripts                Executable permissions, documentation"
+  echo "    $(_cyan " 9.") Repository hygiene     Sensitive files, development artifacts"
+  echo "    $(_cyan "10.") Token budget           Per-file and total token analysis with zones"
+  echo "    $(_cyan "11.") Content quality        Code examples, headings, keyword density"
+  echo "    $(_cyan "12.") Agent metadata         agents/openai.yaml validation"
+  echo "    $(_cyan "13.") Security scan          API keys, secrets, hardcoded paths, dangerous commands"
+  echo "    $(_cyan "14.") Heading hierarchy      Level skip detection (MD001), duplicate headings (MD024)"
+  echo "    $(_cyan "15.") Quality score          0-100 score across 5 dimensions with letter grade"
+  echo ""
+  echo "  $(_bold "Quality Score Dimensions:")"
+  echo "    Description Quality  (30 pts)  Length, specificity, voice, verbs, triggers"
+  echo "    Instruction Clarity  (25 pts)  Code blocks, headings, reference links"
+  echo "    Spec Compliance      (20 pts)  Required fields, name format, token/line limits"
+  echo "    Progressive Discl.   (15 pts)  References dir, linking, body size"
+  echo "    Security             (10 pts)  No secrets, paths, or dangerous commands"
+  echo ""
+  echo "  $(_bold "Letter Grades:")"
+  echo "    $(_bold_green "A+") 95+   $(_bold_green "A") 90+   $(_bold_green "A-") 85+   $(_bold_yellow "B+") 80+   $(_bold_yellow "B") 75+"
+  echo "    $(_bold_yellow "B-") 70+   $(_bold_yellow "C+") 65+   $(_bold_red "C") 60+    $(_bold_red "C-") 55+   $(_bold_red "D") 50+    $(_bold_red "F") <50"
+  echo ""
+  echo "  $(_bold "Token Zones:")"
+  echo "    $(_green "[SAFE]")   Well within budget, efficient"
+  echo "    $(_yellow "[WARN]")   At or near recommended limits"
+  echo "    $(_red "[HIGH]")   Exceeding recommendations, consider optimization"
+  echo ""
+  echo "  $(_bold "Token Estimation:")"
+  echo "    Uses chars/4 approximation (industry standard for tiktoken cl100k_base)"
+  echo "    SKILL.md budget: <5000 tokens | Reference: <4000 tokens each"
+  echo "    Total package: <50000 tokens recommended"
+  echo ""
+  echo "  $(_bold "Severity:")"
+  echo "    $(_red "✗ ERROR")  Spec violation or broken content — must fix"
+  echo "    $(_yellow "! WARN ")  Best practice issue — should fix"
+  echo "    $(_blue "ℹ INFO ")  Suggestion — nice to have"
+  echo "    $(_green "✓ PASS ")  Check passed"
+  echo ""
+  echo "  $(_bold "References:")"
+  echo "    Spec:    https://agentskills.io/specification"
+  echo "    Scoring: https://agentskills.io/skill-creation/best-practices"
+  echo "    Tokens:  https://agentpatterns.ai/context-engineering/context-budget-allocation/"
+  echo ""
+}
+
+_suppress_terminal() {
+  [ "$OUTPUT_MODE" != "terminal" ]
+}
+
+main() {
+  # Parse CLI flags
+  while [ $# -gt 0 ]; do
+    case "$1" in
+      --help|-h) show_help; exit 0 ;;
+      --json) OUTPUT_MODE="json"; NO_COLOR=1 ;;
+      --md|--markdown) OUTPUT_MODE="markdown"; NO_COLOR=1 ;;
+      --score-only|--score) OUTPUT_MODE="score-only" ;;
+      *) echo "Unknown flag: $1"; echo "Run with --help for usage."; exit 2 ;;
+    esac
+    shift
+  done
+
+  if ! _suppress_terminal; then
+    echo ""
+    echo "  $(_bold "╔══════════════════════════════════════════════════════════╗")"
+    echo "  $(_bold "║")                                                          $(_bold "║")"
+    echo "  $(_bold "║")        $(_bold_cyan "A G E N T   S K I L L   S C A N N E R")            $(_bold "║")"
+    echo "  $(_bold "║")                      $(_dim "v 4 . 0")                             $(_bold "║")"
+    echo "  $(_bold "║")                                                          $(_bold "║")"
+    echo "  $(_bold "╚══════════════════════════════════════════════════════════╝")"
+    echo ""
+    if [ "$CI" = "true" ]; then
+      _detail "Mode : CI (GitHub Actions annotations + job summary)"
+    else
+      _detail "Mode : Local"
+    fi
+    _detail "Skill: $(pwd)"
+    _detail "Spec : agentskills.io/specification"
+    _detail "Date : $(date '+%Y-%m-%d %H:%M:%S')"
+    echo ""
+    echo "  $(_dim "Running $TOTAL_CHECKS sections with 20+ individual checks...")"
+  fi
+
+  if [ "$OUTPUT_MODE" != "terminal" ]; then
+    exec 3>&1 1>/dev/null
+  fi
+
+  check_structure
+  check_frontmatter
+  check_body
+  check_links
+  check_references
+  check_markdown
+  check_reference_depth
+  check_scripts
+  check_repo_hygiene
+  check_token_budget
+  check_content_quality
+  check_agents_metadata
+  check_security
+  check_heading_hierarchy
+  compute_quality_score
+
+  if [ "$OUTPUT_MODE" != "terminal" ]; then
+    exec 1>&3 3>&-
+  fi
+
+  case "$OUTPUT_MODE" in
+    json)       print_json ;;
+    markdown)   print_markdown ;;
+    score-only) print_score_only ;;
+    *)          print_report ;;
+  esac
+
+  [ "$ERRORS" -gt 0 ] && exit 1
+  exit 0
+}
+
+main "$@"

+ 107 - 0
.claude/skills/debugging-wizard/SKILL.md

@@ -0,0 +1,107 @@
+---
+name: debugging-wizard
+description: Parses error messages, traces execution flow through stack traces, correlates log entries to identify failure points, and applies systematic hypothesis-driven methodology to isolate and resolve bugs. Use when investigating errors, analyzing stack traces, finding root causes of unexpected behavior, troubleshooting crashes, or performing log analysis, error investigation, or root cause analysis.
+license: MIT
+metadata:
+  author: https://github.com/Jeffallan
+  version: "1.1.0"
+  domain: quality
+  triggers: debug, error, bug, exception, traceback, stack trace, troubleshoot, not working, crash, fix issue
+  role: specialist
+  scope: analysis
+  output-format: analysis
+  related-skills: test-master, fullstack-guardian, monitoring-expert
+---
+
+# Debugging Wizard
+
+Expert debugger applying systematic methodology to isolate and resolve issues in any codebase.
+
+## Core Workflow
+
+1. **Reproduce** - Establish consistent reproduction steps
+2. **Isolate** - Narrow down to smallest failing case
+3. **Hypothesize and test** - Form testable theories, verify/disprove each one
+4. **Fix** - Implement and verify solution
+5. **Prevent** - Add tests/safeguards against regression
+
+## Reference Guide
+
+Load detailed guidance based on context:
+
+<!-- Systematic Debugging row adapted from obra/superpowers by Jesse Vincent (@obra), MIT License -->
+
+| Topic | Reference | Load When |
+|-------|-----------|-----------|
+| Debugging Tools | `references/debugging-tools.md` | Setting up debuggers by language |
+| Common Patterns | `references/common-patterns.md` | Recognizing bug patterns |
+| Strategies | `references/strategies.md` | Binary search, git bisect, time travel |
+| Quick Fixes | `references/quick-fixes.md` | Common error solutions |
+| Systematic Debugging | `references/systematic-debugging.md` | Complex bugs, multiple failed fixes, root cause analysis |
+
+## Constraints
+
+### MUST DO
+- Reproduce the issue first
+- Gather complete error messages and stack traces
+- Test one hypothesis at a time
+- Document findings for future reference
+- Add regression tests after fixing
+- Remove all debug code before committing
+
+### MUST NOT DO
+- Guess without testing
+- Make multiple changes at once
+- Skip reproduction steps
+- Assume you know the cause
+- Debug in production without safeguards
+- Leave console.log/debugger statements in code
+
+## Common Debugging Commands
+
+**Python (pdb)**
+```bash
+python -m pdb script.py          # launch debugger
+# inside pdb:
+# b 42          — set breakpoint at line 42
+# n             — step over
+# s             — step into
+# p some_var    — print variable
+# bt            — print full traceback
+```
+
+**JavaScript (Node.js)**
+```bash
+node --inspect-brk script.js     # pause at first line, attach Chrome DevTools
+# In Chrome: open chrome://inspect → click "inspect"
+# Sources panel: add breakpoints, watch expressions, step through
+```
+
+**Git bisect (regression hunting)**
+```bash
+git bisect start
+git bisect bad                   # current commit is broken
+git bisect good v1.2.0           # last known good tag/commit
+# Git checks out midpoint — test, then:
+git bisect good   # or: git bisect bad
+# Repeat until git identifies the first bad commit
+git bisect reset
+```
+
+**Go (delve)**
+```bash
+dlv debug ./cmd/server           # build & attach
+# (dlv) break main.go:55
+# (dlv) continue
+# (dlv) print myVar
+```
+
+## Output Templates
+
+When debugging, provide:
+1. **Root Cause**: What specifically caused the issue
+2. **Evidence**: Stack trace, logs, or test that proves it
+3. **Fix**: Code change that resolves it
+4. **Prevention**: Test or safeguard to prevent recurrence
+
+[Documentation](https://jeffallan.github.io/claude-skills/skills/quality/debugging-wizard/)

+ 132 - 0
.claude/skills/debugging-wizard/references/common-patterns.md

@@ -0,0 +1,132 @@
+# Common Bug Patterns
+
+## Pattern Recognition
+
+| Pattern | Symptom | Likely Cause |
+|---------|---------|--------------|
+| Race condition | Intermittent failures | Missing await, async timing |
+| Off-by-one | Missing first/last item | `<` vs `<=`, array bounds |
+| Null reference | "undefined is not..." | Missing null check |
+| Memory leak | Growing memory | Uncleaned listeners/intervals |
+| N+1 queries | Slow with more data | Fetching in loop |
+| Type coercion | Unexpected behavior | `==` instead of `===` |
+| Closure issue | Wrong variable value | Loop variable capture |
+| Stale state | Old value used | React state closure |
+
+## Race Condition
+
+```typescript
+// BUG: Race condition
+let data;
+fetchData().then(result => { data = result; });
+console.log(data); // undefined!
+
+// FIX: Await the result
+const data = await fetchData();
+console.log(data);
+```
+
+## Off-by-One
+
+```typescript
+// BUG: Skips last element
+for (let i = 0; i < array.length - 1; i++) { }
+
+// FIX: Include last element
+for (let i = 0; i < array.length; i++) { }
+
+// BUG: Array index out of bounds
+const last = array[array.length]; // undefined
+
+// FIX: Correct index
+const last = array[array.length - 1];
+```
+
+## Null Reference
+
+```typescript
+// BUG: Crashes if user is null
+const name = user.profile.name;
+
+// FIX: Optional chaining
+const name = user?.profile?.name ?? 'Unknown';
+
+// FIX: Guard clause
+if (!user?.profile) {
+  return 'Unknown';
+}
+return user.profile.name;
+```
+
+## Memory Leak
+
+```typescript
+// BUG: Listener never removed
+useEffect(() => {
+  window.addEventListener('resize', handleResize);
+}, []);
+
+// FIX: Cleanup function
+useEffect(() => {
+  window.addEventListener('resize', handleResize);
+  return () => window.removeEventListener('resize', handleResize);
+}, []);
+
+// BUG: Interval never cleared
+setInterval(pollData, 1000);
+
+// FIX: Store and clear
+const intervalId = setInterval(pollData, 1000);
+return () => clearInterval(intervalId);
+```
+
+## Closure in Loop
+
+```typescript
+// BUG: All callbacks use i = 5
+for (var i = 0; i < 5; i++) {
+  setTimeout(() => console.log(i), 100);
+}
+
+// FIX: Use let (block scoped)
+for (let i = 0; i < 5; i++) {
+  setTimeout(() => console.log(i), 100);
+}
+
+// FIX: Capture in closure
+for (var i = 0; i < 5; i++) {
+  ((j) => setTimeout(() => console.log(j), 100))(i);
+}
+```
+
+## React Stale State
+
+```typescript
+// BUG: count is stale in closure
+const [count, setCount] = useState(0);
+useEffect(() => {
+  setInterval(() => {
+    setCount(count + 1); // Always uses initial count
+  }, 1000);
+}, []);
+
+// FIX: Use functional update
+setCount(prev => prev + 1);
+
+// FIX: Include in dependency array with cleanup
+useEffect(() => {
+  const id = setInterval(() => setCount(c => c + 1), 1000);
+  return () => clearInterval(id);
+}, []);
+```
+
+## Quick Reference
+
+| Symptom | First Check |
+|---------|-------------|
+| "undefined is not..." | Null check missing |
+| Works sometimes | Race condition |
+| Wrong value in callback | Closure/stale state |
+| Gets slower over time | Memory leak, N+1 |
+| Off by one item | Loop bounds, array index |
+| Type mismatch | `==` vs `===`, coercion |

+ 140 - 0
.claude/skills/debugging-wizard/references/debugging-tools.md

@@ -0,0 +1,140 @@
+# Debugging Tools
+
+## Debuggers by Language
+
+| Language | Debugger | Start Command |
+|----------|----------|---------------|
+| TypeScript/JS | Node Inspector | `node --inspect` |
+| Python | pdb/ipdb | `python -m pdb` |
+| Go | Delve | `dlv debug` |
+| Rust | rust-gdb/lldb | `rust-gdb ./target/debug/app` |
+| Java | JDB/IDE | IDE debugger |
+
+## Node.js / TypeScript
+
+```bash
+# Start with inspector
+node --inspect dist/main.js
+
+# Break on first line
+node --inspect-brk dist/main.js
+
+# With ts-node
+node --inspect -r ts-node/register src/main.ts
+```
+
+```typescript
+// In code
+debugger; // Breakpoint
+
+// Quick print
+console.log({ variable }); // Shows name and value
+console.table(arrayOfObjects); // Table format
+console.trace('Called from'); // Stack trace
+```
+
+## Python
+
+```bash
+# Start debugger
+python -m pdb script.py
+
+# Post-mortem on exception
+python -m pdb -c continue script.py
+```
+
+```python
+# In code
+breakpoint()  # Python 3.7+
+import pdb; pdb.set_trace()  # Older Python
+
+# Quick print
+print(f"{variable=}")  # Python 3.8+ shows name and value
+
+# Rich debugging
+from rich import inspect
+inspect(object, methods=True)
+```
+
+### pdb Commands
+
+| Command | Action |
+|---------|--------|
+| `n` | Next line |
+| `s` | Step into |
+| `c` | Continue |
+| `l` | List code |
+| `p expr` | Print expression |
+| `pp expr` | Pretty print |
+| `w` | Where (stack) |
+| `q` | Quit |
+
+## Go
+
+```bash
+# Start delve
+dlv debug ./cmd/app
+
+# Attach to running process
+dlv attach <pid>
+
+# Debug test
+dlv test ./pkg/...
+```
+
+```go
+// Quick print
+log.Printf("%+v", variable) // With field names
+fmt.Printf("%#v\n", variable) // Go syntax representation
+
+// Spew for complex structures
+import "github.com/davecgh/go-spew/spew"
+spew.Dump(variable)
+```
+
+### Delve Commands
+
+| Command | Action |
+|---------|--------|
+| `break main.go:42` | Set breakpoint |
+| `continue` | Continue |
+| `next` | Next line |
+| `step` | Step into |
+| `print var` | Print variable |
+| `goroutines` | List goroutines |
+
+## VS Code Debug Config
+
+```json
+// .vscode/launch.json
+{
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "type": "node",
+      "request": "launch",
+      "name": "Debug TypeScript",
+      "program": "${workspaceFolder}/src/main.ts",
+      "preLaunchTask": "tsc: build",
+      "outFiles": ["${workspaceFolder}/dist/**/*.js"]
+    },
+    {
+      "type": "python",
+      "request": "launch",
+      "name": "Debug Python",
+      "program": "${workspaceFolder}/main.py",
+      "console": "integratedTerminal"
+    }
+  ]
+}
+```
+
+## Quick Reference
+
+| Need | Tool |
+|------|------|
+| Breakpoint in code | `debugger;` / `breakpoint()` |
+| Print with name | `console.log({x})` / `print(f"{x=}")` |
+| Stack trace | `console.trace()` / `traceback.print_stack()` |
+| Inspect object | `console.dir(obj)` / `dir(obj)` |
+| Step through | IDE debugger or CLI debugger |

+ 177 - 0
.claude/skills/debugging-wizard/references/quick-fixes.md

@@ -0,0 +1,177 @@
+# Quick Fixes
+
+## TypeError: Cannot read property 'x' of undefined
+
+```typescript
+// Error
+user.profile.name
+// user or profile is undefined
+
+// Fix: Optional chaining
+user?.profile?.name
+
+// Fix: Default value
+user?.profile?.name ?? 'Unknown'
+
+// Fix: Guard clause
+if (!user?.profile) {
+  return null;
+}
+return user.profile.name;
+```
+
+## Unhandled Promise Rejection
+
+```typescript
+// Error
+fetchData().then(process);
+// What if fetchData rejects?
+
+// Fix: Add catch
+fetchData()
+  .then(process)
+  .catch(error => {
+    console.error('Fetch failed:', error);
+  });
+
+// Fix: try/catch with await
+try {
+  const data = await fetchData();
+  await process(data);
+} catch (error) {
+  console.error('Operation failed:', error);
+}
+```
+
+## React: Too Many Re-renders
+
+```typescript
+// Error: Calling setState during render
+function Component() {
+  const [count, setCount] = useState(0);
+  setCount(count + 1); // Infinite loop!
+}
+
+// Fix: Use useEffect for side effects
+function Component() {
+  const [count, setCount] = useState(0);
+  useEffect(() => {
+    setCount(c => c + 1);
+  }, []); // Only on mount
+}
+
+// Error: Object/array in dependency array
+useEffect(() => {}, [{ a: 1 }]); // New object every render!
+
+// Fix: Memoize or use primitives
+const config = useMemo(() => ({ a: 1 }), []);
+useEffect(() => {}, [config]);
+```
+
+## CORS Error
+
+```typescript
+// Browser blocks cross-origin request
+
+// Fix 1: Server - Add CORS headers
+app.use(cors({
+  origin: 'http://localhost:3000',
+  credentials: true,
+}));
+
+// Fix 2: Proxy in development (Vite)
+// vite.config.ts
+export default {
+  server: {
+    proxy: {
+      '/api': 'http://localhost:8000',
+    },
+  },
+};
+```
+
+## Maximum Call Stack Size Exceeded
+
+```typescript
+// Error: Infinite recursion
+function factorial(n) {
+  return n * factorial(n - 1); // No base case!
+}
+
+// Fix: Add base case
+function factorial(n) {
+  if (n <= 1) return 1;
+  return n * factorial(n - 1);
+}
+
+// Error: Circular dependency in objects
+const a = {};
+const b = { ref: a };
+a.ref = b;
+JSON.stringify(a); // Fails!
+
+// Fix: Break circular reference
+JSON.stringify(a, (key, value) => {
+  if (key === 'ref') return '[Circular]';
+  return value;
+});
+```
+
+## Module Not Found
+
+```bash
+# Error: Cannot find module 'x'
+
+# Fix 1: Install the package
+npm install x
+
+# Fix 2: Check import path
+import x from './x';     # Relative - needs ./
+import x from 'x';       # Package - no ./
+
+# Fix 3: Check file extension
+import x from './x.js';  # ESM may need extension
+
+# Fix 4: Clear cache
+rm -rf node_modules package-lock.json
+npm install
+```
+
+## Async/Await Issues
+
+```typescript
+// Error: await in non-async function
+function getData() {
+  const data = await fetch('/api'); // SyntaxError!
+}
+
+// Fix: Mark function as async
+async function getData() {
+  const data = await fetch('/api');
+}
+
+// Error: forEach doesn't await
+items.forEach(async item => {
+  await process(item); // Doesn't wait!
+});
+
+// Fix: Use for...of
+for (const item of items) {
+  await process(item);
+}
+
+// Fix: Use Promise.all for parallel
+await Promise.all(items.map(item => process(item)));
+```
+
+## Quick Reference
+
+| Error Message | Likely Fix |
+|--------------|------------|
+| Cannot read property of undefined | Optional chaining `?.` |
+| Unhandled promise rejection | Add `.catch()` or try/catch |
+| Too many re-renders | Remove setState from render |
+| CORS error | Add CORS headers on server |
+| Maximum call stack | Add recursion base case |
+| Module not found | Check path, install package |
+| await in non-async | Add `async` keyword |

+ 142 - 0
.claude/skills/debugging-wizard/references/strategies.md

@@ -0,0 +1,142 @@
+# Debugging Strategies
+
+## Binary Search
+
+Divide and conquer to find the bug location.
+
+```markdown
+1. Comment out/disable half the code
+2. Test if bug still occurs
+3. If yes: bug is in remaining half
+4. If no: bug is in disabled half
+5. Repeat until isolated
+```
+
+```typescript
+// Example: Bug in data processing pipeline
+async function process(data) {
+  const step1 = await transform(data);
+  // Bug somewhere below?
+
+  const step2 = await validate(step1);
+  console.log('After step2:', step2); // Check here
+
+  const step3 = await enrich(step2);
+  const step4 = await save(step3);
+  return step4;
+}
+```
+
+## Minimal Reproduction
+
+Strip away everything until only the bug remains.
+
+```markdown
+1. Create new minimal project
+2. Add only code needed to reproduce
+3. Remove dependencies one by one
+4. Simplify inputs to smallest failing case
+5. Document exact reproduction steps
+```
+
+```typescript
+// Instead of debugging full app
+// Create minimal test case:
+const input = { id: null }; // Minimal failing input
+const result = processUser(input);
+console.log(result); // Isolate the exact failure
+```
+
+## Git Bisect
+
+Find the commit that introduced the bug.
+
+```bash
+# Start bisect
+git bisect start
+
+# Mark current commit as bad
+git bisect bad
+
+# Mark known good commit
+git bisect good v1.0.0
+
+# Git checks out middle commit
+# Test and mark:
+git bisect good  # or
+git bisect bad
+
+# Repeat until found
+# Git will say: "abc123 is the first bad commit"
+
+# End bisect
+git bisect reset
+```
+
+```bash
+# Automated bisect with test script
+git bisect start HEAD v1.0.0
+git bisect run npm test
+```
+
+## Time Travel Debugging
+
+Work backwards from the failure.
+
+```markdown
+1. Start at the error/failure point
+2. What value caused it? Where did that come from?
+3. Trace backwards through the code
+4. Find where the value diverged from expected
+```
+
+```typescript
+// Error: Cannot read 'name' of undefined at line 45
+
+// Line 45: const name = user.name;
+// Q: Why is user undefined?
+
+// Line 40: const user = users.find(u => u.id === id);
+// Q: Why didn't find() return a user?
+
+// Check: Is the id correct? Are users populated?
+console.log({ id, users, user });
+```
+
+## Rubber Duck Debugging
+
+Explain the problem step by step.
+
+```markdown
+1. State what the code should do
+2. Explain what it actually does
+3. Walk through the code line by line
+4. Describe what each line does
+5. The discrepancy often becomes obvious
+```
+
+## Delta Debugging
+
+When something recently broke.
+
+```bash
+# Check what changed
+git diff HEAD~5..HEAD
+
+# Check specific file history
+git log -p --follow -- src/problematic-file.ts
+
+# Find when file last worked
+git log --oneline -- src/problematic-file.ts
+```
+
+## Quick Reference
+
+| Strategy | Best For |
+|----------|----------|
+| Binary Search | Unknown bug location |
+| Minimal Repro | Complex bugs, reporting |
+| Git Bisect | Regression bugs |
+| Time Travel | Known error location |
+| Rubber Duck | Logic errors |
+| Delta Debug | Recent breakage |

+ 367 - 0
.claude/skills/debugging-wizard/references/systematic-debugging.md

@@ -0,0 +1,367 @@
+# Systematic Debugging
+
+---
+
+## Core Principle
+
+> **NO FIXES WITHOUT ROOT CAUSE INVESTIGATION FIRST.**
+
+Jumping to fixes without understanding causes creates more bugs. Systematic debugging prevents the "fix one thing, break two more" cycle.
+
+---
+
+## The Four Mandatory Phases
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│                    SYSTEMATIC DEBUGGING                      │
+├─────────────────────────────────────────────────────────────┤
+│  Phase 1: ROOT CAUSE INVESTIGATION                          │
+│  ├── Read error messages thoroughly                         │
+│  ├── Reproduce reliably with documented steps               │
+│  ├── Examine recent changes                                 │
+│  └── Trace data flow backward                               │
+├─────────────────────────────────────────────────────────────┤
+│  Phase 2: PATTERN ANALYSIS                                   │
+│  ├── Find similar working implementations                   │
+│  ├── Study reference implementations completely             │
+│  └── Document all differences                               │
+├─────────────────────────────────────────────────────────────┤
+│  Phase 3: HYPOTHESIS TESTING                                 │
+│  ├── Form specific, written hypothesis                      │
+│  ├── Test with minimal, isolated changes                    │
+│  └── One variable at a time                                 │
+├─────────────────────────────────────────────────────────────┤
+│  Phase 4: IMPLEMENTATION                                     │
+│  ├── Create failing test case                               │
+│  ├── Implement single fix addressing root cause             │
+│  └── Verify no new breakage                                 │
+└─────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Phase 1: Root Cause Investigation
+
+**Objective:** Understand exactly what is failing and why before attempting any fix.
+
+### Step 1.1: Read Error Messages Thoroughly
+
+```bash
+# Don't just read the first line
+TypeError: Cannot read property 'map' of undefined
+    at UserList.render (UserList.tsx:24)
+    at renderWithHooks (react-dom.js:14985)
+    at mountIndeterminateComponent (react-dom.js:17811)
+```
+
+**Key questions:**
+- What exact operation failed?
+- Where in the code (file, line)?
+- What was the call stack?
+- Are there multiple errors or just one?
+
+### Step 1.2: Reproduce Reliably
+
+```markdown
+## Reproduction Steps
+1. Navigate to /users
+2. Click "Load More" button
+3. Wait for loading spinner
+4. **ERROR: "Cannot read property 'map' of undefined"**
+
+## Environment
+- Browser: Chrome 120
+- User: Admin role
+- Data state: 50+ users in database
+```
+
+**Requirement:** Document exact steps that reproduce the bug 100% of the time.
+
+### Step 1.3: Examine Recent Changes
+
+```bash
+# What changed recently?
+git log --oneline -10
+
+# What specifically changed in the failing file?
+git log -p UserList.tsx
+
+# When did this start failing?
+git bisect start
+git bisect bad HEAD
+git bisect good v1.2.0
+```
+
+### Step 1.4: Trace Data Flow Backward
+
+```typescript
+// Error happens here:
+users.map(u => u.name)  // users is undefined
+
+// Trace backward:
+// Where does 'users' come from?
+const users = props.users;
+
+// Where do props come from?
+<UserList users={data.users} />
+
+// Where does data come from?
+const { data } = useQuery(GET_USERS);
+
+// ROOT CAUSE: Query returns { users: null } when loading
+```
+
+### Step 1.5: Add Diagnostic Instrumentation
+
+```typescript
+// Add temporary logging at boundaries
+console.log('[UserList] props:', JSON.stringify(props));
+console.log('[UserList] users type:', typeof props.users);
+console.log('[UserList] users value:', props.users);
+
+// Check at data source
+console.log('[API] Response:', response);
+console.log('[API] Response.data:', response.data);
+```
+
+---
+
+## Phase 2: Pattern Analysis
+
+**Objective:** Find working examples to understand what correct behavior looks like.
+
+### Step 2.1: Locate Similar Working Implementations
+
+```bash
+# Find similar components that work correctly
+grep -r "useQuery" src/components/ --include="*.tsx"
+
+# Find how other lists handle loading states
+grep -r "loading" src/components/*List* --include="*.tsx"
+```
+
+### Step 2.2: Study Reference Implementations Completely
+
+```typescript
+// WORKING: ProductList.tsx
+function ProductList({ products, loading }) {
+  if (loading) return <Spinner />;
+  if (!products) return null;  // ← Handles undefined case
+
+  return products.map(p => <ProductItem key={p.id} {...p} />);
+}
+
+// BROKEN: UserList.tsx
+function UserList({ users, loading }) {
+  if (loading) return <Spinner />;
+  // Missing: !users check
+
+  return users.map(u => <UserItem key={u.id} {...u} />);  // 💥 Crashes
+}
+```
+
+### Step 2.3: Document All Differences
+
+| Aspect | Working (ProductList) | Broken (UserList) |
+|--------|----------------------|-------------------|
+| Null check | `if (!products)` | Missing |
+| Default value | `products ?? []` | None |
+| Loading handled | Before render | Before render |
+| Error handled | Returns ErrorState | Missing |
+
+---
+
+## Phase 3: Hypothesis Testing
+
+**Objective:** Verify your understanding with controlled experiments.
+
+### Step 3.1: Form Specific, Written Hypothesis
+
+```markdown
+## Hypothesis #1
+**Statement:** The crash occurs because `users` is undefined when the
+query is complete but returns no data.
+
+**Prediction:** Adding a null check before `.map()` will prevent the crash.
+
+**Test:** Add `if (!users) return null;` before the map call.
+```
+
+### Step 3.2: Test with Minimal Changes
+
+```typescript
+// Change ONLY one thing
+function UserList({ users, loading }) {
+  if (loading) return <Spinner />;
+  if (!users) return null;  // ← Single change
+
+  return users.map(u => <UserItem key={u.id} {...u} />);
+}
+```
+
+### Step 3.3: One Variable at a Time
+
+```markdown
+## Test Results
+
+| Hypothesis | Change | Result | Conclusion |
+|------------|--------|--------|------------|
+| #1: Null check | Add `if (!users)` | ✓ Pass | Confirmed |
+
+Do NOT test multiple hypotheses simultaneously.
+```
+
+---
+
+## Phase 4: Implementation
+
+**Objective:** Fix the bug permanently with proper safeguards.
+
+### Step 4.1: Create Failing Test Case First
+
+```typescript
+describe('UserList', () => {
+  it('should handle undefined users gracefully', () => {
+    // This test should FAIL before the fix
+    const { container } = render(<UserList users={undefined} loading={false} />);
+    expect(container).not.toThrow();
+    expect(screen.queryByRole('list')).not.toBeInTheDocument();
+  });
+});
+```
+
+### Step 4.2: Implement Single Fix
+
+```typescript
+function UserList({ users, loading }: UserListProps) {
+  if (loading) return <Spinner />;
+  if (!users || users.length === 0) {
+    return <EmptyState message="No users found" />;
+  }
+
+  return (
+    <ul role="list">
+      {users.map(u => <UserItem key={u.id} {...u} />)}
+    </ul>
+  );
+}
+```
+
+### Step 4.3: Verify No New Breakage
+
+```bash
+# Run full test suite
+npm test
+
+# Run specific component tests
+npm test UserList
+
+# Run integration tests
+npm run test:integration
+
+# Verify in browser
+# 1. Normal case: 50 users
+# 2. Empty case: 0 users
+# 3. Loading case: spinner shows
+# 4. Error case: error message shows
+```
+
+---
+
+## The Three-Fix Threshold
+
+> **After 3 failed fix attempts → STOP.**
+
+Three failures in different locations signals architectural problems, not isolated bugs.
+
+### What Three Failures Means
+
+```
+Fix Attempt 1: Added null check → New error in child component
+Fix Attempt 2: Fixed child component → New error in parent
+Fix Attempt 3: Fixed parent → Original error returns
+                              ↓
+                    STOP. QUESTION ARCHITECTURE.
+```
+
+### At the Threshold, Do This
+
+1. **Stop fixing symptoms**
+2. **Document the pattern** of failures
+3. **Identify architectural assumptions** being violated
+4. **Propose structural change** rather than patch
+5. **Discuss with team** before proceeding
+
+---
+
+## Red Flags Requiring Process Reset
+
+When you notice these, stop and restart from Phase 1:
+
+| Red Flag | Why It's Wrong |
+|----------|----------------|
+| Proposing solutions before tracing data flow | Guessing, not debugging |
+| Making multiple simultaneous changes | Can't identify which change worked |
+| Skipping test creation | Bug will recur |
+| "Let's try this and see if it works" | Shotgun debugging |
+| Fixing without understanding the cause | Band-aid, not cure |
+
+---
+
+## Decision Flowchart
+
+```
+                    ┌──────────────────┐
+                    │   Bug Reported   │
+                    └────────┬─────────┘
+                             │
+              ┌──────────────▼──────────────┐
+              │   Can you reproduce it?      │
+              └──────────────┬──────────────┘
+                    No       │       Yes
+            ┌────────────────┴────────────────┐
+            ▼                                  ▼
+    ┌───────────────┐               ┌─────────────────┐
+    │ Get more info │               │ Trace data flow │
+    └───────────────┘               └────────┬────────┘
+                                             │
+                              ┌──────────────▼──────────────┐
+                              │ Do you understand the cause? │
+                              └──────────────┬──────────────┘
+                                    No       │       Yes
+                    ┌────────────────────────┴─────────┐
+                    ▼                                   ▼
+            ┌───────────────┐               ┌─────────────────┐
+            │ Study working │               │ Write hypothesis│
+            │   examples    │               └────────┬────────┘
+            └───────────────┘                        │
+                                             ┌───────▼───────┐
+                                             │  Write test   │
+                                             └───────┬───────┘
+                                                     │
+                                             ┌───────▼───────┐
+                                             │  Implement    │
+                                             └───────┬───────┘
+                                                     │
+                                  ┌──────────────────▼──────────────────┐
+                                  │          Does test pass?            │
+                                  └──────────────────┬──────────────────┘
+                                            No       │       Yes
+                            ┌────────────────────────┴──────────┐
+                            ▼                                    ▼
+                    ┌───────────────┐                  ┌─────────────────┐
+                    │ Attempt < 3?  │                  │      Done       │
+                    └───────┬───────┘                  └─────────────────┘
+                    No      │      Yes
+            ┌───────────────┴─────────────────┐
+            ▼                                  ▼
+    ┌───────────────────┐          ┌─────────────────────┐
+    │ Question          │          │ Return to Phase 1   │
+    │ architecture      │          └─────────────────────┘
+    └───────────────────┘
+```
+
+---
+
+*Content adapted from [obra/superpowers](https://github.com/obra/superpowers) by Jesse Vincent (@obra), MIT License.*

+ 1082 - 0
.claude/skills/kmp-compose-multiplatform/SKILL.md

@@ -0,0 +1,1082 @@
+---
+name: kmp-compose-multiplatform
+description: Expert Kotlin Multiplatform (KMP) and Compose Multiplatform development guidance. Use when creating, reviewing, or modifying KMP projects with Compose UI, clean architecture, and multi-platform targets (Android, iOS, Desktop, Web).
+---
+
+# Kotlin Multiplatform + Compose Multiplatform Skill
+
+You are an expert in **Kotlin Multiplatform (KMP)** and **Compose Multiplatform** development. You follow Google's official architecture guidelines (as demonstrated in Now in Android), JetBrains Compose best practices, and the KMP community standards.
+
+## Core Principles
+
+1. **Maximize shared code** — write once in `commonMain`, use everywhere
+2. **Clean Architecture** — strict layer separation: Data → Domain → Presentation
+3. **Feature-based modularization** — organize by feature, not by layer
+4. **Unidirectional Data Flow (UDF)** — state flows down, events flow up
+5. **Interface-first design** — define contracts, inject implementations
+6. **Platform parity** — same behavior on Android and iOS unless explicitly platform-specific
+
+---
+
+## Project Structure
+
+### Recommended Module Layout
+
+```
+root/
+├── app/                          # Android app entry point
+├── iosApp/                       # iOS app entry point (Xcode project)
+├── shared/                       # KMP shared module (or multi-module)
+│   └── src/
+│       ├── commonMain/           # Shared code for all platforms
+│       ├── androidMain/          # Android-specific implementations
+│       ├── iosMain/              # iOS-specific implementations
+│       └── commonTest/           # Shared tests
+├── build-logic/                  # Convention plugins (if multi-module)
+│   └── convention/               # Gradle convention plugins
+└── gradle/
+    └── libs.versions.toml        # Version catalog (ALWAYS use this)
+```
+
+### Feature Module Layout (inside commonMain)
+
+Each feature must follow this exact structure:
+
+```
+feature/
+└── [feature-name]/
+    ├── data/
+    │   ├── local/
+    │   │   ├── dao/              # Room DAOs
+    │   │   └── entity/           # Room entities
+    │   ├── remote/               # API services
+    │   ├── repository/           # Repository implementations
+    │   └── mapper/               # Data ↔ Domain mappers
+    ├── domain/
+    │   ├── model/                # Domain models (pure Kotlin)
+    │   ├── repository/           # Repository interfaces
+    │   └── usecase/              # Use cases (one action per class)
+    ├── presentation/
+    │   ├── ui/                   # Composable screens and components
+    │   ├── viewmodel/            # ViewModels
+    │   └── state/                # UI state data classes
+    └── di/                       # Koin module for this feature
+```
+
+---
+
+## Architecture Guidelines
+
+### Layer Responsibilities
+
+**Data Layer**
+- Implements repository interfaces from domain
+- Maps data models to/from domain models
+- Handles network requests (Ktor) and local persistence (Room/DataStore)
+- Never exposes data models to domain or presentation
+
+**Domain Layer**
+- Pure Kotlin — NO Android/platform dependencies
+- Repository interfaces (abstractions)
+- Use cases: single public function `operator fun invoke()`
+- Domain models (not database entities, not DTOs)
+
+**Presentation Layer**
+- ViewModels hold `StateFlow<UiState>` — never expose mutable state
+- UI State is a sealed class or data class
+- Composables receive state + callbacks (no direct ViewModel access in nested composables)
+- Navigation handled at screen level only
+
+### Resource/Result Pattern
+
+Always use a sealed class for async results with **typed domain errors** (never raw strings):
+
+```kotlin
+sealed class Resource<out T> {
+    data class Success<out T>(val data: T) : Resource<T>()
+    data class Error(val error: AppError) : Resource<Nothing>()
+    data object Loading : Resource<Nothing>()
+}
+```
+
+See `references/error-handling.md` for the full `AppError` hierarchy, `safeApiCall` wrapper, and error-to-UI-message mapping.
+
+### Use Case Pattern
+
+```kotlin
+class GetUserUseCase(private val repository: UserRepository) {
+    suspend operator fun invoke(userId: String): Resource<User> {
+        return repository.getUser(userId)
+    }
+}
+```
+
+### ViewModel Pattern
+
+Use `data class` UiState (not sealed class) for composable state with `_uiState.update { }`. Expose navigation events via a separate `SharedFlow`:
+
+```kotlin
+class HomeViewModel(
+    private val getItemsUseCase: GetItemsUseCase
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(HomeUiState())
+    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
+
+    // One-time navigation/event channel — never put navigation in UiState
+    private val _events = MutableSharedFlow<HomeEvent>()
+    val events: SharedFlow<HomeEvent> = _events.asSharedFlow()
+
+    fun loadItems() {
+        viewModelScope.launch {
+            _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+            getItemsUseCase()
+                .onSuccess { items ->
+                    _uiState.update { it.copy(isLoading = false, items = items) }
+                }
+                .onError { error ->
+                    _uiState.update { it.copy(isLoading = false, errorMessage = error.toUserMessage()) }
+                }
+        }
+    }
+
+    fun onItemClicked(id: String) {
+        viewModelScope.launch {
+            _events.emit(HomeEvent.NavigateToDetail(id))
+        }
+    }
+}
+
+// Flat data class — preferred over sealed class for composable state
+data class HomeUiState(
+    val isLoading: Boolean = false,
+    val items: List<Item> = emptyList(),
+    val errorMessage: String? = null   // human-readable, never AppError
+)
+
+// One-time events — navigation, toasts, analytics
+sealed class HomeEvent {
+    data class NavigateToDetail(val id: String) : HomeEvent()
+    data object ShowUndoSnackbar : HomeEvent()
+}
+```
+
+Collect events in the screen composable:
+
+```kotlin
+@Composable
+fun HomeScreen(
+    navController: NavHostController,
+    viewModel: HomeViewModel = koinViewModel()
+) {
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+    // Collect one-time events
+    LaunchedEffect(Unit) {
+        viewModel.events.collect { event ->
+            when (event) {
+                is HomeEvent.NavigateToDetail -> navController.navigate(Screen.Detail.createRoute(event.id))
+                is HomeEvent.ShowUndoSnackbar -> { /* show snackbar */ }
+            }
+        }
+    }
+
+    HomeContent(uiState = uiState, onItemClick = viewModel::onItemClicked)
+}
+```
+
+### StateFlow from Repository Flow
+
+Use `stateIn()` to convert a repository `Flow` into a ViewModel `StateFlow`:
+
+```kotlin
+val uiState: StateFlow<HomeUiState> = itemsRepository.observeItems()
+    .map { items -> HomeUiState(items = items) }
+    .stateIn(
+        scope = viewModelScope,
+        started = SharingStarted.WhileSubscribed(5_000),
+        initialValue = HomeUiState(isLoading = true)
+    )
+```
+
+---
+
+## Kotlin Multiplatform Patterns
+
+### Expect/Actual Pattern
+
+Use expect/actual for platform-specific implementations:
+
+```kotlin
+// commonMain
+expect fun getPlatformName(): String
+
+expect class DatabaseBuilder(context: Any?) {
+    fun build(): AppDatabase
+}
+```
+
+```kotlin
+// androidMain
+actual fun getPlatformName(): String = "Android"
+
+actual class DatabaseBuilder actual constructor(private val context: Any?) {
+    actual fun build(): AppDatabase =
+        Room.databaseBuilder(context as Context, AppDatabase::class.java, "app.db").build()
+}
+```
+
+```kotlin
+// iosMain
+actual fun getPlatformName(): String = "iOS"
+
+actual class DatabaseBuilder actual constructor(context: Any?) {
+    actual fun build(): AppDatabase {
+        val dbFilePath = NSHomeDirectory() + "/app.db"
+        return Room.databaseBuilder<AppDatabase>(name = dbFilePath).build()
+    }
+}
+```
+
+### Source Set Configuration (build.gradle.kts)
+
+```kotlin
+kotlin {
+    androidTarget {
+        compilations.all {
+            compileTaskProvider.configure {
+                compilerOptions {
+                    jvmTarget.set(JvmTarget.JVM_17)
+                }
+            }
+        }
+    }
+
+    listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { target ->
+        target.binaries.framework {
+            baseName = "shared"
+            isStatic = true
+        }
+    }
+
+    sourceSets {
+        commonMain.dependencies {
+            // Compose Multiplatform
+            implementation(compose.runtime)
+            implementation(compose.foundation)
+            implementation(compose.material3)
+            implementation(compose.ui)
+            implementation(compose.components.resources)
+
+            // Navigation
+            implementation(libs.navigation.compose)
+
+            // Koin
+            implementation(libs.koin.core)
+            implementation(libs.koin.compose)
+            implementation(libs.koin.compose.viewmodel)
+
+            // Ktor
+            implementation(libs.ktor.client.core)
+            implementation(libs.ktor.client.content.negotiation)
+            implementation(libs.ktor.serialization.kotlinx.json)
+
+            // Room
+            implementation(libs.room.runtime)
+            implementation(libs.room.ktx)
+
+            // DataStore
+            implementation(libs.datastore.preferences)
+
+            // DateTime
+            implementation(libs.kotlinx.datetime)
+
+            // Serialization
+            implementation(libs.kotlinx.serialization.json)
+
+            // Coroutines
+            implementation(libs.kotlinx.coroutines.core)
+        }
+
+        androidMain.dependencies {
+            implementation(libs.ktor.client.okhttp)
+            implementation(libs.koin.android)
+            implementation(libs.kotlinx.coroutines.android)
+        }
+
+        iosMain.dependencies {
+            implementation(libs.ktor.client.darwin)
+        }
+    }
+}
+```
+
+---
+
+## Dependency Injection with Koin
+
+### Module Structure
+
+```kotlin
+// feature/home/di/HomeModule.kt
+val homeModule = module {
+    single<HomeRepository> { HomeRepositoryImpl(get(), get()) }
+    factory { GetHomeDataUseCase(get()) }
+    viewModel { HomeViewModel(get()) }
+}
+```
+
+### Scopes — Feature-Scoped Dependencies
+
+Use Koin scopes for dependencies that should live only as long as a feature/screen is active (e.g., a shopping cart, a multi-step form):
+
+```kotlin
+// Define a scope qualifier
+val CartScope = named("CartScope")
+
+val cartModule = module {
+    // Scoped — one instance per CartScope lifecycle
+    scope(CartScope) {
+        scoped { CartRepository(get()) }
+        scoped { CartViewModel(get()) }
+    }
+}
+
+// Open scope when entering the feature
+val cartScope = getKoin().createScope("cart_session", CartScope)
+val cartViewModel = cartScope.get<CartViewModel>()
+
+// Close scope when leaving — instance is garbage collected
+cartScope.close()
+```
+
+### Lazy Injection
+
+Use `inject()` (lazy delegation) instead of `get()` (eager) when the dependency may not be needed immediately:
+
+```kotlin
+class HomeViewModel : ViewModel() {
+    private val analyticsService: AnalyticsService by inject()   // lazy
+    private val repository: HomeRepository = get()               // eager
+}
+```
+
+### Named Qualifiers
+
+Use `named()` qualifiers when you need multiple instances of the same type in the same module — a common pattern for multiple API clients or dispatchers:
+
+```kotlin
+val networkModule = module {
+    // Two HTTP clients with different base URLs, distinguished by name
+    single<HttpClient>(named("main")) {
+        provideHttpClient(baseUrl = BuildKonfig.API_BASE_URL, tokenProvider = get())
+    }
+    single<HttpClient>(named("auth")) {
+        provideHttpClient(baseUrl = BuildKonfig.AUTH_BASE_URL, tokenProvider = get())
+    }
+
+    // Multiple dispatchers
+    single<CoroutineDispatcher>(named("io")) { Dispatchers.IO }
+    single<CoroutineDispatcher>(named("main")) { Dispatchers.Main }
+}
+
+// Inject by name
+class UserRepository(
+    private val mainClient: HttpClient = get(named("main")),
+    private val authClient: HttpClient = get(named("auth"))
+)
+```
+
+### ViewModel with SavedStateHandle
+
+Bind `SavedStateHandle` in Koin using `viewModelOf` or the `params` API:
+
+```kotlin
+// Using viewModelOf — automatically injects SavedStateHandle
+val featureModule = module {
+    viewModelOf(::DetailViewModel)  // SavedStateHandle injected automatically
+}
+
+// Or manually via params
+val featureModule = module {
+    viewModel { params ->
+        DetailViewModel(
+            savedStateHandle = params.get(),
+            getItemUseCase = get()
+        )
+    }
+}
+```
+
+```kotlin
+class DetailViewModel(
+    savedStateHandle: SavedStateHandle,
+    private val getItemUseCase: GetItemUseCase
+) : ViewModel() {
+    private val itemId: String = checkNotNull(savedStateHandle[Screen.Detail.ARG_ID])
+}
+```
+
+### Central Module Aggregator
+
+```kotlin
+// di/AppModule.kt
+fun getAllModules() = listOf(
+    platformModule(),
+    coreModule,
+    authModule,
+    homeModule,
+    // ... other feature modules
+)
+
+// Platform-specific (expect/actual)
+expect fun platformModule(): Module
+```
+
+### Koin Initialization
+
+```kotlin
+// KoinInitializer.kt
+fun initKoin(appDeclaration: KoinAppDeclaration = {}) {
+    startKoin {
+        appDeclaration()
+        modules(getAllModules())
+    }
+}
+```
+
+Android entry (Application class):
+```kotlin
+class MyApp : Application() {
+    override fun onCreate() {
+        super.onCreate()
+        initKoin {
+            androidContext(this@MyApp)
+        }
+    }
+}
+```
+
+iOS entry (Swift):
+```swift
+KoinInitializerKt.doInitKoin()
+```
+
+---
+
+## Build System
+
+### Version Catalog (gradle/libs.versions.toml)
+
+Always use the version catalog. Never hardcode versions in build files:
+
+```toml
+[versions]
+kotlin = "2.3.0"
+compose-multiplatform = "1.10.1"
+agp = "8.8.0"
+koin = "4.1.1"
+ktor = "3.0.3"
+room = "2.8.4"
+datastore = "1.1.1"
+navigation-compose = "2.9.1"
+kotlinx-coroutines = "1.10.2"
+kotlinx-serialization = "1.7.3"
+kotlinx-datetime = "0.6.1"
+ksp = "2.3.0-1.0.32"
+buildkonfig = "0.17.1"
+coil = "3.0.4"
+
+[libraries]
+# Koin
+koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
+koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
+koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
+koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
+# Ktor
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
+ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
+ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
+# Room
+room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
+room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
+room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
+# DataStore
+datastore-preferences = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" }
+# Navigation
+navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
+# KotlinX
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
+kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
+# Coil
+coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
+coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
+
+[plugins]
+kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+android-library = { id = "com.android.library", version.ref = "agp" }
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+room = { id = "androidx.room", version.ref = "room" }
+buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfig" }
+```
+
+### BuildKonfig for Environment Configuration
+
+```kotlin
+// build.gradle.kts
+buildkonfig {
+    packageName = "com.example.shared"
+
+    defaultConfigs {
+        buildConfigField(STRING, "ENVIRONMENT", "stage")
+        buildConfigField(BOOLEAN, "IS_DEBUG", "true")
+        buildConfigField(STRING, "API_BASE_URL", "https://api.stage.example.com")
+    }
+
+    targetConfigs("prod") {
+        buildConfigField(STRING, "ENVIRONMENT", "prod")
+        buildConfigField(BOOLEAN, "IS_DEBUG", "false")
+        buildConfigField(STRING, "API_BASE_URL", "https://api.example.com")
+    }
+}
+```
+
+---
+
+## Data Persistence
+
+### Room Database Setup
+
+```kotlin
+// commonMain
+@Database(entities = [UserEntity::class], version = 2)
+abstract class AppDatabase : RoomDatabase() {
+    abstract fun userDao(): UserDao
+}
+```
+
+```kotlin
+// androidMain — actual
+actual class DatabaseBuilder actual constructor(private val context: Any?) {
+    actual fun build(): AppDatabase = Room.databaseBuilder<AppDatabase>(
+        context = context as Context,
+        name = context.getDatabasePath("app.db").absolutePath
+    )
+    .addMigrations(MIGRATION_1_2)
+    .build()
+}
+
+val MIGRATION_1_2 = object : Migration(1, 2) {
+    override fun migrate(db: SupportSQLiteDatabase) {
+        db.execSQL("ALTER TABLE users ADD COLUMN avatar_url TEXT")
+    }
+}
+```
+
+```kotlin
+// iosMain — actual
+actual class DatabaseBuilder actual constructor(context: Any?) {
+    actual fun build(): AppDatabase = Room.databaseBuilder<AppDatabase>(
+        name = NSHomeDirectory() + "/app.db"
+    )
+    .addMigrations(MIGRATION_1_2)
+    .build()
+}
+```
+
+### Room DAO — Reactive Queries
+
+Always use `Flow<List<T>>` for queries that the UI observes — never return a raw `List`:
+
+```kotlin
+@Dao
+interface UserDao {
+    // Reactive — emits whenever the table changes
+    @Query("SELECT * FROM users ORDER BY name ASC")
+    fun observeAll(): Flow<List<UserEntity>>
+
+    // One-shot suspend for writes
+    @Upsert
+    suspend fun upsert(user: UserEntity)
+
+    @Delete
+    suspend fun delete(user: UserEntity)
+
+    @Query("SELECT * FROM users WHERE id = :id")
+    suspend fun getById(id: String): UserEntity?
+
+    // Transaction for atomic multi-step operations
+    @Transaction
+    suspend fun replaceAll(users: List<UserEntity>) {
+        deleteAll()
+        insertAll(users)
+    }
+
+    @Query("DELETE FROM users")
+    suspend fun deleteAll()
+
+    @Insert(onConflict = OnConflictStrategy.IGNORE)
+    suspend fun insertAll(users: List<UserEntity>)
+}
+```
+
+### Room Pagination with Paging 3
+
+For large datasets use `PagingSource` — never load everything into memory:
+
+```toml
+# libs.versions.toml
+paging = "3.3.6"
+[libraries]
+paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
+paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
+paging-testing = { module = "androidx.paging:paging-testing", version.ref = "paging" }
+```
+
+```kotlin
+// DAO — return PagingSource instead of List
+@Dao
+interface ItemDao {
+    @Query("SELECT * FROM items ORDER BY created_at DESC")
+    fun pagingSource(): PagingSource<Int, ItemEntity>
+}
+
+// Repository
+fun observeItemsPaged(): Flow<PagingData<Item>> = Pager(
+    config = PagingConfig(pageSize = 20, enablePlaceholders = false),
+    pagingSourceFactory = { itemDao.pagingSource() }
+).flow.map { pagingData -> pagingData.map { it.toDomain() } }
+
+// ViewModel
+val pagedItems: Flow<PagingData<Item>> = itemsRepository
+    .observeItemsPaged()
+    .cachedIn(viewModelScope)
+
+// Composable
+@Composable
+fun ItemListScreen(viewModel: HomeViewModel = koinViewModel()) {
+    val items = viewModel.pagedItems.collectAsLazyPagingItems()
+
+    LazyColumn {
+        items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
+            items[index]?.let { ItemCard(item = it) }
+        }
+        item {
+            when (items.loadState.append) {
+                is LoadState.Loading -> CircularProgressIndicator()
+                is LoadState.Error -> RetryButton(onClick = { items.retry() })
+                else -> Unit
+            }
+        }
+    }
+}
+```
+
+### Room Full-Text Search (FTS)
+
+```kotlin
+@Fts4(contentEntity = ItemEntity::class)
+@Entity(tableName = "items_fts")
+data class ItemFtsEntity(
+    @PrimaryKey @ColumnInfo(name = "rowid") val rowId: Int = 0,
+    val title: String,
+    val description: String
+)
+
+@Dao
+interface ItemSearchDao {
+    @Query("SELECT * FROM items WHERE rowid IN (SELECT rowid FROM items_fts WHERE items_fts MATCH :query)")
+    fun search(query: String): Flow<List<ItemEntity>>
+}
+```
+
+### DataStore Setup
+
+```kotlin
+// commonMain
+expect fun createDataStore(producePath: () -> String): DataStore<Preferences>
+
+internal const val DATASTORE_FILE = "app_prefs.preferences_pb"
+```
+
+---
+
+## Networking with Ktor
+
+```kotlin
+// commonMain
+class ApiService(private val client: HttpClient) {
+
+    suspend fun getUser(id: String): UserDto =
+        client.get("/users/$id").body()
+
+    suspend fun createUser(request: CreateUserRequest): UserDto =
+        client.post("/users") {
+            contentType(ContentType.Application.Json)
+            setBody(request)
+        }.body()
+}
+
+// HTTP client setup (in DI module) — with auth, logging, and retry
+fun provideHttpClient(
+    baseUrl: String,
+    tokenProvider: TokenProvider
+): HttpClient = HttpClient {
+    install(ContentNegotiation) {
+        json(Json {
+            ignoreUnknownKeys = true
+            isLenient = true
+        })
+    }
+    install(HttpTimeout) {
+        requestTimeoutMillis = 30_000
+        connectTimeoutMillis = 15_000
+        socketTimeoutMillis = 30_000
+    }
+    install(Logging) {
+        logger = Logger.DEFAULT
+        level = if (BuildKonfig.IS_DEBUG) LogLevel.HEADERS else LogLevel.NONE
+    }
+    // Automatic retry for transient failures
+    install(HttpRequestRetry) {
+        retryOnServerErrors(maxRetries = 3)
+        retryOnException(maxRetries = 3, retryOnTimeout = true)
+        exponentialDelay(base = 2.0, maxDelayMs = 10_000)
+    }
+    // Auth token injection
+    install(Auth) {
+        bearer {
+            loadTokens { BearerTokens(tokenProvider.getAccessToken(), tokenProvider.getRefreshToken()) }
+            refreshTokens {
+                val newTokens = tokenProvider.refresh()
+                BearerTokens(newTokens.accessToken, newTokens.refreshToken)
+            }
+        }
+    }
+    defaultRequest {
+        url(baseUrl)
+        header(HttpHeaders.ContentType, ContentType.Application.Json)
+    }
+}
+```
+
+Always wrap API calls with `safeApiCall` to map Ktor exceptions to domain errors — see `references/error-handling.md`.
+
+### Exponential Backoff with Jitter
+
+Add randomization to retry delays to prevent thundering-herd problems when many clients retry simultaneously:
+
+```kotlin
+install(HttpRequestRetry) {
+    retryOnServerErrors(maxRetries = 3)
+    retryOnException(maxRetries = 3, retryOnTimeout = true)
+    // Add jitter: randomize delay within ±500ms of calculated backoff
+    exponentialDelay(base = 2.0, maxDelayMs = 10_000, randomizationMs = 500)
+}
+```
+
+### Certificate Pinning (Android)
+
+For high-security apps, pin the server's certificate to prevent MITM attacks:
+
+```kotlin
+// androidMain — OkHttp CertificatePinner
+actual fun createHttpClient(baseUrl: String, tokenProvider: TokenProvider): HttpClient =
+    HttpClient(OkHttp) {
+        engine {
+            config {
+                certificatePinner(
+                    CertificatePinner.Builder()
+                        .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
+                        .add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup pin
+                        .build()
+                )
+            }
+        }
+        // ... other config
+    }
+```
+
+> Keep two pins active at all times (primary + backup) to allow certificate rotation without a forced update.
+
+### SharedFlow Buffer Strategy
+
+Choose buffer size and overflow behavior explicitly when emitting from multiple coroutines:
+
+```kotlin
+// One-time UI events (navigation, toasts) — no replay, drop oldest if consumer is slow
+private val _events = MutableSharedFlow<HomeEvent>(
+    replay = 0,
+    extraBufferCapacity = 64,
+    onBufferOverflow = BufferOverflow.DROP_OLDEST
+)
+
+// App-wide events (logout, session expiry) — replay 1 so late subscribers catch the event
+private val _appEvents = MutableSharedFlow<AppEvent>(
+    replay = 1,
+    extraBufferCapacity = 16,
+    onBufferOverflow = BufferOverflow.DROP_OLDEST
+)
+```
+
+Never use `replay > 0` for navigation events — a screen re-subscribing would navigate again.
+
+### HTTP Caching
+
+Enable response caching in the Ktor client to reduce network calls and support offline reading:
+
+```kotlin
+// androidMain — OkHttp cache
+actual fun createHttpClient(baseUrl: String, tokenProvider: TokenProvider): HttpClient =
+    HttpClient(OkHttp) {
+        engine {
+            config {
+                cache(Cache(
+                    directory = context.cacheDir.resolve("http_cache"),
+                    maxSize = 10L * 1024 * 1024  // 10 MB
+                ))
+            }
+        }
+        // ... other plugins
+    }
+```
+
+For stale-while-revalidate behaviour, add headers in repository calls:
+
+```kotlin
+suspend fun getItems(): Resource<List<ItemDto>> = safeApiCall {
+    client.get("/items") {
+        header(HttpHeaders.CacheControl, "max-age=300")   // fresh for 5 min
+    }.body()
+}
+```
+
+### OAuth 2.0 Token Refresh
+
+The Ktor `Auth` plugin handles token rotation automatically. Ensure the refresh call itself is unauthenticated to avoid infinite loops:
+
+```kotlin
+install(Auth) {
+    bearer {
+        loadTokens {
+            BearerTokens(tokenStorage.accessToken, tokenStorage.refreshToken)
+        }
+        refreshTokens {
+            // markAsRefreshTokenRequest() prevents Auth plugin re-intercepting this call
+            val response = client.post("/auth/refresh") {
+                markAsRefreshTokenRequest()
+                setBody(RefreshRequest(oldTokens?.refreshToken ?: ""))
+            }.body<TokenResponse>()
+
+            tokenStorage.save(response.accessToken, response.refreshToken)
+            BearerTokens(response.accessToken, response.refreshToken)
+        }
+        sendWithoutRequest { request ->
+            request.url.host == "api.example.com"   // only attach token to your API
+        }
+    }
+}
+
+---
+
+## Internationalization (i18n)
+
+All user-facing strings must use Compose Multiplatform's resource system. Never hardcode text:
+
+```kotlin
+// GOOD — uses generated Res.string references
+Text(text = stringResource(Res.string.home_title, userName))
+Button(onClick = onRetry) { Text(text = stringResource(Res.string.action_retry)) }
+
+// BAD — hardcoded, not translatable
+Text(text = "Welcome, $userName")
+```
+
+Key rules:
+- Define all strings in `commonMain/composeResources/values/strings.xml`
+- Add locale folders (`values-es/`, `values-ar/`) for each supported language
+- Use `pluralStringResource()` for quantities — never `if (count == 1)` string branching
+- Use `start`/`end` padding (not `left`/`right`) for RTL language support
+- Use `Icons.AutoMirrored.*` for directional icons that should flip in RTL
+- Test with `@Preview(locale = "ar")` to verify RTL layouts
+
+See `references/i18n.md` for plurals, RTL testing, dynamic locale change, and locale-aware number/currency formatting.
+
+---
+
+## Testing Strategy
+
+### Unit Tests (commonTest)
+
+```kotlin
+class GetUserUseCaseTest {
+
+    private val repository = FakeUserRepository()
+    private val useCase = GetUserUseCase(repository)
+
+    @Test
+    fun `returns success when repository succeeds`() = runTest {
+        repository.setUser(testUser)
+        val result = useCase("user-123")
+        assertIs<Resource.Success<User>>(result)
+        assertEquals(testUser, result.data)
+    }
+}
+
+// Fake (not mock) — real implementation of the interface
+class FakeUserRepository : UserRepository {
+    private var user: User? = null
+
+    fun setUser(user: User) { this.user = user }
+
+    override suspend fun getUser(id: String): Resource<User> =
+        user?.let { Resource.Success(it) } ?: Resource.Error("Not found")
+}
+```
+
+**Rules**:
+- Never use Mockito or MockK — use fakes/test doubles
+- All shared tests go in `commonTest`
+- Platform-specific tests in `androidTest`/`iosTest`
+- Use `runTest` from `kotlinx-coroutines-test` for coroutine testing
+
+---
+
+## Logging
+
+Use `expect`/`actual` for platform logging — **never use `println()`** in production code. Never log sensitive data (tokens, passwords, PII).
+
+```kotlin
+// commonMain — log levels matching platform conventions
+enum class LogLevel { DEBUG, INFO, WARN, ERROR }
+
+expect fun logDebug(tag: String, message: String)
+expect fun logInfo(tag: String, message: String)
+expect fun logWarn(tag: String, message: String)
+expect fun logError(tag: String, message: String, throwable: Throwable? = null)
+```
+
+```kotlin
+// androidMain — use Timber for structured Android logging
+actual fun logDebug(tag: String, message: String) = Timber.tag(tag).d(message)
+actual fun logInfo(tag: String, message: String) = Timber.tag(tag).i(message)
+actual fun logWarn(tag: String, message: String) = Timber.tag(tag).w(message)
+actual fun logError(tag: String, message: String, throwable: Throwable?) =
+    Timber.tag(tag).e(throwable, message)
+```
+
+Initialize Timber in `Application.onCreate()` — plant a `DebugTree` for debug builds and a **Crashlytics reporting tree** for production:
+
+```kotlin
+class MyApp : Application() {
+    override fun onCreate() {
+        super.onCreate()
+        if (BuildKonfig.IS_DEBUG) {
+            Timber.plant(Timber.DebugTree())
+        } else {
+            Timber.plant(CrashlyticsTree())
+        }
+    }
+}
+
+// Production tree — routes WARN/ERROR to Firebase Crashlytics
+class CrashlyticsTree : Timber.Tree() {
+    override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
+        // Only forward warnings and errors to crash reporting
+        if (priority < Log.WARN) return
+        // Never log sensitive data — scrub before sending
+        val safeMessage = message.redactSensitivePatterns()
+        FirebaseCrashlytics.getInstance().log("[$tag] $safeMessage")
+        if (t != null) FirebaseCrashlytics.getInstance().recordException(t)
+    }
+}
+
+// Redact tokens, emails, phone numbers before sending to crash services
+private fun String.redactSensitivePatterns(): String = this
+    .replace(Regex("Bearer [A-Za-z0-9\\-._~+/]+=*"), "Bearer [REDACTED]")
+    .replace(Regex("[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}"), "[EMAIL REDACTED]")
+```
+
+```kotlin
+// iosMain — use os_log (not NSLog, which is deprecated for structured logging)
+import platform.Foundation.NSLog
+import platform.darwin.*
+
+actual fun logDebug(tag: String, message: String) {
+    os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_DEBUG, "[$tag] $message")
+}
+actual fun logInfo(tag: String, message: String) {
+    os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_INFO, "[$tag] $message")
+}
+actual fun logWarn(tag: String, message: String) {
+    os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_ERROR, "[$tag] WARN: $message")
+}
+actual fun logError(tag: String, message: String, throwable: Throwable?) {
+    os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_FAULT, "[$tag] ERROR: $message ${throwable?.message ?: ""}")
+}
+```
+
+---
+
+## Common Pitfalls to Avoid
+
+1. **Never put Android/iOS imports in `commonMain`** — use expect/actual
+2. **Never expose Flow from Room directly to UI** — map through repository to domain models first
+3. **Never use `LiveData` in KMP** — use `StateFlow`/`Flow` only
+4. **Never hardcode strings in Compose** — use `stringResource()` from compose resources
+5. **Never use `rememberCoroutineScope` in a ViewModel** — use `viewModelScope`
+6. **Never pass `Context` through layers** — inject at the platform module level only
+7. **Never use `GlobalScope`** — use structured concurrency with `viewModelScope` or `CoroutineScope(SupervisorJob())`
+8. **Avoid `LaunchedEffect` for ViewModel operations** — use `collectAsStateWithLifecycle()`
+9. **Never share mutable state across composables** — hoist to a single source of truth
+10. **Do not skip the domain layer** — even for simple features, maintain the abstraction
+11. **Never use raw `String` for errors in `Resource.Error`** — use typed `AppError` sealed class
+12. **Never put navigation calls in `UiState`** — use a separate `SharedFlow<Event>` for one-time events
+13. **Never call `stopKoin()` in production code** — only in test teardown
+14. **Never use `println()` for logging** — use `expect/actual` log functions
+15. **Never skip Room migrations** — always add a `Migration` object when bumping the schema version
+16. **Never omit `contentDescription` on meaningful images/icons** — required for accessibility (TalkBack, VoiceOver)
+17. **Never log sensitive data** — redact tokens, emails, and PII before sending to Crashlytics or any log aggregation service
+18. **Never load unbounded lists** — use `PagingSource` + `Pager` for large datasets
+19. **Never hardcode user-facing strings** — always use `stringResource()` from compose resources
+20. **Never use `left`/`right` padding in Composables** — use `start`/`end` for RTL language support
+21. **Never use `Icons.Default.ArrowBack` for navigation** — use `Icons.AutoMirrored.Filled.ArrowBack` to mirror in RTL
+22. **Never use `reply > 0` on navigation event SharedFlows** — late subscribers would trigger navigation again
+23. **Never keep only one certificate pin** — always pin primary + backup to allow rotation without forcing an update
+24. **Never use `left`/`right` in column/row alignment** — prefer `Start`/`End` which respect layout direction
+
+---
+
+## Reference Files
+
+- `references/architecture.md` — detailed architecture guide, module structures, feature flags, inter-feature communication, proto DataStore
+- `references/compose-best-practices.md` — composable design, `@Stable`, state hoisting, Material 3, focus management, text field accessibility, dynamic type, previews, performance
+- `references/error-handling.md` — `AppError` hierarchy, `safeApiCall`, recoverable vs fatal, 429 handling, error analytics/breadcrumbs, retry logic
+- `references/testing.md` — fakes, ViewModel tests with Turbine, SharedFlow event testing, Paging tests, screenshot/golden tests, Compose UI tests, Room in-memory
+- `references/ios-interop.md` — Swift naming conventions, SKIE sealed class edge cases, Kotlin/Native memory model, iOS performance, nullability bridging, coroutines↔Swift Concurrency
+- `references/navigation.md` — deep links, cross-module navigation contracts, predictive back, deep link validation, nested nav, bottom navigation, back handling, transitions, `SavedStateHandle`
+- `references/build-system.md` — convention plugins, R8/ProGuard, publishing to Maven, CI Gradle daemon, KSP config, `gradle.properties`, build performance
+- `references/i18n.md` — string resources, plurals, RTL support, dynamic locale change, locale-aware number/currency formatting
+
+## Official References
+
+- [KMP Documentation](https://www.jetbrains.com/help/kotlin-multiplatform-dev/)
+- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/)
+- [Android Architecture Guide](https://developer.android.com/topic/architecture)
+- [Now in Android (Reference App)](https://github.com/android/nowinandroid)
+- [Room KMP](https://developer.android.com/kotlin/multiplatform/room)
+- [DataStore KMP](https://developer.android.com/topic/libraries/architecture/datastore)
+- [Koin Multiplatform](https://insert-koin.io/docs/reference/koin-mp/kmp)
+- [Ktor Client](https://ktor.io/docs/client-create-multiplatform-application.html)
+- [Navigation Compose](https://developer.android.com/guide/navigation/design/kotlin-dsl)
+- [Compose Localization](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-resources-usage.html)
+- [Predictive Back](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture)
+- [Paging 3](https://developer.android.com/topic/libraries/architecture/paging/v3-overview)

+ 522 - 0
.claude/skills/kmp-compose-multiplatform/references/architecture.md

@@ -0,0 +1,522 @@
+# Architecture Guide — KMP + Compose Multiplatform
+
+Based on [Now in Android](https://github.com/android/nowinandroid) and [Android Architecture Guide](https://developer.android.com/topic/architecture).
+
+## Overview
+
+This guide defines the architecture used in KMP + Compose Multiplatform projects. The architecture follows three primary layers with strict dependency rules.
+
+```
+UI (Compose) → Domain (Use Cases) → Data (Repository) → Sources (API/DB)
+```
+
+Dependencies only flow **inward** — domain never depends on UI, data never depends on UI.
+
+---
+
+## Layer Definitions
+
+### Presentation Layer
+
+Responsibility: Display state and capture user events.
+
+**Components:**
+- `Screen` composables — stateless UI
+- `ViewModel` — holds `StateFlow<UiState>`, exposes events
+- `UiState` — sealed class/data class representing screen state
+
+**Rules:**
+- ViewModels may only depend on use cases (never repositories directly)
+- Composables may only depend on state + callbacks (never ViewModels directly below the screen level)
+- State is always `StateFlow`, never `LiveData`
+- Never launch coroutines from composables — use `LaunchedEffect` sparingly or ViewModel events
+
+### Domain Layer
+
+Responsibility: Business logic and abstraction contracts.
+
+**Components:**
+- `UseCase` classes — single `operator fun invoke()`, one responsibility each
+- Repository `interface` declarations
+- Domain `model` classes (pure Kotlin data classes)
+
+**Rules:**
+- Zero Android or platform dependencies — pure Kotlin only
+- Each use case does exactly one thing
+- Repository interfaces live here, implementations in data layer
+- Domain models are never database entities or DTOs
+
+### Data Layer
+
+Responsibility: Data access, transformation, and persistence.
+
+**Components:**
+- Repository `Impl` classes
+- Remote data sources (`ApiService` via Ktor)
+- Local data sources (Room DAOs, DataStore)
+- `Mapper` functions — DTO ↔ Domain, Entity ↔ Domain
+- DTO and Entity data classes
+
+**Rules:**
+- Data models (DTOs, entities) never escape this layer
+- All repository methods return `Resource<T>` or `Flow<Resource<T>>`
+- Mappers are pure functions, never classes
+
+---
+
+## Module Structure
+
+### Single-Module KMP (recommended for shared libraries)
+
+```
+shared/
+└── src/
+    ├── commonMain/kotlin/com/example/shared/
+    │   ├── core/
+    │   │   ├── data/          # AppDatabase, DataStore setup
+    │   │   ├── di/            # Core Koin module
+    │   │   └── util/          # Resource, extensions
+    │   ├── feature/
+    │   │   ├── auth/          # Auth feature
+    │   │   ├── home/          # Home feature
+    │   │   └── settings/      # Settings feature
+    │   ├── presentation/
+    │   │   └── ui/
+    │   │       ├── navigation/ # AppNavigation, Screen
+    │   │       ├── theme/      # AppTheme, colors, typography
+    │   │       └── components/ # Shared composables
+    │   ├── di/
+    │   │   └── AppModule.kt   # getAllModules() aggregator
+    │   └── KoinInitializer.kt
+    ├── androidMain/kotlin/
+    └── iosMain/kotlin/
+```
+
+### Multi-Module KMP (for larger apps — Now in Android pattern)
+
+```
+root/
+├── app/                       # Android entry point
+├── iosApp/                    # iOS entry point
+├── build-logic/               # Convention plugins
+│   └── convention/
+│       └── src/main/kotlin/
+│           ├── KmpLibraryPlugin.kt
+│           ├── ComposePlugin.kt
+│           └── KoinPlugin.kt
+├── core/
+│   ├── data/                  # Base repository classes
+│   ├── database/              # Room setup
+│   ├── network/               # Ktor client
+│   ├── datastore/             # DataStore
+│   ├── ui/                    # Shared composables
+│   └── designsystem/          # Theme, colors, typography
+└── feature/
+    ├── auth/
+    │   ├── api/               # Navigation contract + interfaces
+    │   └── impl/              # Full feature implementation
+    └── home/
+        ├── api/
+        └── impl/
+```
+
+---
+
+## Dependency Rules (Multi-Module)
+
+```
+app → feature:*:impl, feature:*:api, core:*
+feature:impl → feature:*:api (never other impls), core:*
+feature:api → core:designsystem (for navigation types only)
+core:data → core:network, core:database, core:datastore
+core:network → (external only)
+core:database → (external only)
+core:designsystem → (external + compose only)
+```
+
+**Never:**
+- `feature:api` → other `feature` modules
+- `core:*` → `feature:*`
+- `core:*` → `app`
+- Domain layer → Data layer
+
+---
+
+## State Management
+
+### Unidirectional Data Flow (UDF)
+
+```
+User Action → ViewModel Event → Repository → UseCase → ViewModel State → UI
+```
+
+### UiState Pattern
+
+```kotlin
+// Simple state
+data class HomeUiState(
+    val isLoading: Boolean = false,
+    val items: List<Item> = emptyList(),
+    val error: String? = null
+)
+
+// OR sealed class for mutually exclusive states
+sealed class HomeUiState {
+    data object Loading : HomeUiState()
+    data class Success(val items: List<Item>) : HomeUiState()
+    data class Error(val message: String) : HomeUiState()
+    data object Idle : HomeUiState()
+}
+```
+
+### Collecting State in Compose
+
+```kotlin
+// Use collectAsStateWithLifecycle for lifecycle-aware collection
+val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+```
+
+---
+
+## Navigation Architecture
+
+Navigation is defined at the top-level (`AppNavigation.kt`) and uses type-safe sealed class routes.
+
+### Route Definitions
+
+```kotlin
+sealed class Screen(val route: String) {
+    data object Splash : Screen("splash")
+    data object Home : Screen("home")
+    data object Detail : Screen("detail/{id}") {
+        fun createRoute(id: String) = "detail/$id"
+        const val ARG_ID = "id"
+    }
+}
+```
+
+### NavHost Setup
+
+```kotlin
+@Composable
+fun AppNavigation(
+    startDestination: String = Screen.Splash.route,
+    navController: NavHostController = rememberNavController()
+) {
+    NavHost(
+        navController = navController,
+        startDestination = startDestination
+    ) {
+        composable(
+            route = Screen.Splash.route,
+            exitTransition = { fadeOut() }
+        ) {
+            SplashScreen(
+                onNavigateToHome = {
+                    navController.navigate(Screen.Home.route) {
+                        popUpTo(Screen.Splash.route) { inclusive = true }
+                    }
+                }
+            )
+        }
+
+        composable(Screen.Home.route) {
+            HomeScreen(navController = navController)
+        }
+
+        composable(
+            route = Screen.Detail.route,
+            arguments = listOf(navArgument(Screen.Detail.ARG_ID) { type = NavType.StringType })
+        ) { backStackEntry ->
+            val id = backStackEntry.arguments?.getString(Screen.Detail.ARG_ID) ?: return@composable
+            DetailScreen(id = id, onBack = navController::popBackStack)
+        }
+    }
+}
+```
+
+---
+
+## Testing Philosophy
+
+Based on Now in Android's approach:
+
+> "The app does not use any mocking libraries. This is a deliberate choice to ensure tests exercise real code paths."
+
+### Test Doubles (Fakes over Mocks)
+
+```kotlin
+// Interface (in domain layer)
+interface UserRepository {
+    suspend fun getUser(id: String): Resource<User>
+    fun observeUsers(): Flow<List<User>>
+}
+
+// Fake (in commonTest)
+class FakeUserRepository : UserRepository {
+    private val users = mutableMapOf<String, User>()
+    private val usersFlow = MutableStateFlow<List<User>>(emptyList())
+
+    fun addUser(user: User) {
+        users[user.id] = user
+        usersFlow.value = users.values.toList()
+    }
+
+    override suspend fun getUser(id: String): Resource<User> =
+        users[id]?.let { Resource.Success(it) } ?: Resource.Error("User $id not found")
+
+    override fun observeUsers(): Flow<List<User>> = usersFlow
+}
+```
+
+### Test Structure
+
+```
+commonTest/
+├── feature/
+│   └── home/
+│       ├── domain/usecase/GetHomeDataUseCaseTest.kt
+│       └── presentation/viewmodel/HomeViewModelTest.kt
+└── fake/
+    ├── FakeUserRepository.kt
+    └── FakeNetworkClient.kt
+```
+
+---
+
+## iOS SPM Integration
+
+### Package.swift — Wrapper Pattern
+
+KMP projects expose a compiled XCFramework to iOS via Swift Package Manager. The key pattern is a **Wrapper target** that bridges the binary with Swift-only dependencies:
+
+```
+Package.swift
+├── products
+│   └── MyShared (library) → MySharedWrapper
+├── targets
+│   ├── MySharedBinary (binaryTarget) ← XCFramework zip from GitHub Releases
+│   └── MySharedWrapper (target)
+│       ├── depends on: MySharedBinary
+│       └── depends on: PurchasesHybridCommon (or other Swift packages)
+```
+
+**Why not expose the binaryTarget directly?**
+`binaryTarget` cannot declare Swift package dependencies. The wrapper solves this — it's an empty Swift target whose only job is to re-export the binary alongside Swift dependencies.
+
+### Local vs Remote SPM
+
+| Mode | When | How |
+|------|------|-----|
+| Local | Development | `File → Add Package Dependencies → Add Local` → select repo root |
+| Remote | Production | `File → Add Package Dependencies` → enter GitHub repo URL |
+
+The `Package.swift` is **auto-generated by CI** on every release — never edit it manually. The release workflow computes the SHA256 checksum of the XCFramework zip and writes it into `Package.swift` automatically.
+
+---
+
+## Feature Flags
+
+Use a `FeatureFlagRepository` backed by local config (BuildKonfig/hardcoded) and optionally remote config (Firebase Remote Config) to gate features at runtime:
+
+```kotlin
+// commonMain/core/featureflags/FeatureFlag.kt
+enum class FeatureFlag(val key: String, val defaultValue: Boolean) {
+    NEW_HOME_UI("new_home_ui", false),
+    DARK_MODE_V2("dark_mode_v2", true),
+    PAYMENT_REDESIGN("payment_redesign", false)
+}
+
+interface FeatureFlagRepository {
+    fun isEnabled(flag: FeatureFlag): Boolean
+    fun observe(flag: FeatureFlag): Flow<Boolean>
+}
+```
+
+```kotlin
+// Local implementation (always available — no network dependency)
+class LocalFeatureFlagRepository : FeatureFlagRepository {
+    // Overrides from build config or hardcoded for testing
+    private val overrides = mutableMapOf<String, Boolean>()
+
+    override fun isEnabled(flag: FeatureFlag): Boolean =
+        overrides[flag.key] ?: flag.defaultValue
+
+    override fun observe(flag: FeatureFlag): Flow<Boolean> =
+        flowOf(isEnabled(flag))
+
+    fun override(flag: FeatureFlag, enabled: Boolean) {
+        overrides[flag.key] = enabled
+    }
+}
+```
+
+```kotlin
+// androidMain — Firebase Remote Config backed (production)
+class RemoteFeatureFlagRepository(
+    private val remoteConfig: FirebaseRemoteConfig,
+    private val local: LocalFeatureFlagRepository
+) : FeatureFlagRepository {
+
+    override fun isEnabled(flag: FeatureFlag): Boolean =
+        remoteConfig.getBoolean(flag.key)  // falls back to defaultValue
+
+    override fun observe(flag: FeatureFlag): Flow<Boolean> = flow {
+        emit(isEnabled(flag))
+        remoteConfig.fetchAndActivate().addOnCompleteListener {
+            // Re-emit after remote fetch
+        }
+    }
+}
+```
+
+Gate UI at the Composable level — never in domain layer:
+
+```kotlin
+@Composable
+fun HomeScreen(
+    viewModel: HomeViewModel = koinViewModel(),
+    featureFlags: FeatureFlagRepository = get()
+) {
+    val showNewUI by featureFlags.observe(FeatureFlag.NEW_HOME_UI)
+        .collectAsStateWithLifecycle(initialValue = false)
+
+    if (showNewUI) NewHomeContent() else LegacyHomeContent()
+}
+```
+
+---
+
+## Inter-Feature Communication
+
+Features must never depend on each other's implementations. Use one of two patterns:
+
+### Pattern 1: Shared Domain Event via SharedFlow (for cross-feature events)
+
+Define events in `core:domain` — a module every feature depends on:
+
+```kotlin
+// core/domain/events/AppEvents.kt
+sealed class AppEvent {
+    data class UserLoggedOut(val reason: String) : AppEvent()
+    data class CartItemAdded(val itemId: String) : AppEvent()
+    data object SessionExpired : AppEvent()
+}
+
+interface AppEventBus {
+    val events: SharedFlow<AppEvent>
+    suspend fun emit(event: AppEvent)
+}
+```
+
+```kotlin
+// core/data/events/AppEventBusImpl.kt
+class AppEventBusImpl : AppEventBus {
+    private val _events = MutableSharedFlow<AppEvent>(
+        replay = 0,
+        extraBufferCapacity = 64,
+        onBufferOverflow = BufferOverflow.DROP_OLDEST
+    )
+    override val events: SharedFlow<AppEvent> = _events.asSharedFlow()
+    override suspend fun emit(event: AppEvent) = _events.emit(event)
+}
+```
+
+Any feature ViewModel subscribes in `init {}`:
+
+```kotlin
+class CartViewModel(
+    private val eventBus: AppEventBus,
+    private val clearCartUseCase: ClearCartUseCase
+) : ViewModel() {
+    init {
+        viewModelScope.launch {
+            eventBus.events.filterIsInstance<AppEvent.UserLoggedOut>()
+                .collect { clearCartUseCase() }
+        }
+    }
+}
+```
+
+### Pattern 2: Feature API Contract (for navigation/callbacks)
+
+Each feature exposes a public API in its `:api` module — other features depend only on the API:
+
+```kotlin
+// feature/auth/api/AuthFeatureApi.kt  (in feature:auth:api)
+interface AuthFeatureApi {
+    fun loginRoute(): String
+    fun onLoginSuccess(navController: NavController)
+}
+```
+
+```kotlin
+// feature/auth/impl  implements AuthFeatureApi
+class AuthFeatureApiImpl : AuthFeatureApi {
+    override fun loginRoute() = Screen.Login.route
+    override fun onLoginSuccess(navController: NavController) {
+        navController.navigate(Screen.Home.route) {
+            popUpTo(0) { inclusive = true }
+        }
+    }
+}
+```
+
+Register in DI, inject where needed — no direct impl dependency required.
+
+---
+
+## Proto DataStore (Typed Preferences)
+
+Use Proto DataStore for typed, schema-versioned preferences. Prefer over `Preferences DataStore` when the data model is complex:
+
+```protobuf
+// commonMain/proto/user_preferences.proto
+syntax = "proto3";
+option java_package = "com.example.shared.datastore";
+
+message UserPreferences {
+  string theme = 1;          // "light" | "dark" | "system"
+  bool notifications_enabled = 2;
+  string language_code = 3;
+  int32 schema_version = 4;
+}
+```
+
+```kotlin
+// commonMain
+object UserPreferencesSerializer : Serializer<UserPreferences> {
+    override val defaultValue: UserPreferences = UserPreferences(
+        theme = "system",
+        notificationsEnabled = true,
+        languageCode = "en",
+        schemaVersion = 1
+    )
+    override suspend fun readFrom(input: InputStream): UserPreferences =
+        UserPreferences.ADAPTER.decode(input)
+    override suspend fun writeTo(t: UserPreferences, output: OutputStream) =
+        t.encode(output)
+}
+
+class UserPreferencesRepository(private val dataStore: DataStore<UserPreferences>) {
+    val preferences: Flow<UserPreferences> = dataStore.data
+
+    suspend fun setTheme(theme: String) {
+        dataStore.updateData { it.copy(theme = theme) }
+    }
+}
+```
+
+---
+
+## Official Resources
+
+| Resource | URL |
+|----------|-----|
+| Now in Android | https://github.com/android/nowinandroid |
+| Android Architecture Guide | https://developer.android.com/topic/architecture |
+| Compose Architecture | https://developer.android.com/develop/ui/compose/architecture |
+| Compose State | https://developer.android.com/develop/ui/compose/state |
+| Modularization Guide | https://developer.android.com/topic/modularization |
+| KMP Getting Started | https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-getting-started.html |
+| Room KMP | https://developer.android.com/kotlin/multiplatform/room |

+ 564 - 0
.claude/skills/kmp-compose-multiplatform/references/build-system.md

@@ -0,0 +1,564 @@
+# Build System — KMP + Compose Multiplatform
+
+References: [Convention Plugins](https://developer.android.com/topic/modularization/build-logic) | [Version Catalog](https://docs.gradle.org/current/userguide/platforms.html) | [KSP](https://kotlinlang.org/docs/ksp-overview.html)
+
+---
+
+## gradle.properties
+
+Essential settings for KMP builds:
+
+```properties
+# gradle.properties
+
+# Kotlin
+kotlin.code.style=official
+kotlin.mpp.androidSourceSetLayoutVersion=2
+
+# Android
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+
+# Build performance
+org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC
+org.gradle.parallel=true
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+
+# Compose
+org.jetbrains.compose.experimental.uikit.enabled=true
+```
+
+---
+
+## Gradle Wrapper
+
+Always pin the Gradle version in `gradle/wrapper/gradle-wrapper.properties`:
+
+```properties
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
+```
+
+Use Gradle 8.10+ with AGP 8.8+ and Kotlin 2.x.
+
+---
+
+## Convention Plugins (build-logic)
+
+For multi-module projects, extract all build logic into convention plugins to keep module `build.gradle.kts` files minimal and consistent.
+
+### build-logic structure
+
+```
+build-logic/
+└── convention/
+    ├── build.gradle.kts
+    └── src/main/kotlin/
+        ├── KmpLibraryPlugin.kt
+        ├── KmpLibraryComposePlugin.kt
+        ├── AndroidApplicationPlugin.kt
+        └── KoinPlugin.kt
+```
+
+### build-logic/convention/build.gradle.kts
+
+```kotlin
+plugins {
+    `kotlin-dsl`
+}
+
+dependencies {
+    compileOnly(libs.android.gradlePlugin)
+    compileOnly(libs.kotlin.gradlePlugin)
+    compileOnly(libs.compose.gradlePlugin)
+    compileOnly(libs.ksp.gradlePlugin)
+}
+
+// Register all convention plugins
+gradlePlugin {
+    plugins {
+        register("kmpLibrary") {
+            id = "convention.kmp.library"
+            implementationClass = "KmpLibraryPlugin"
+        }
+        register("kmpLibraryCompose") {
+            id = "convention.kmp.library.compose"
+            implementationClass = "KmpLibraryComposePlugin"
+        }
+        register("androidApplication") {
+            id = "convention.android.application"
+            implementationClass = "AndroidApplicationPlugin"
+        }
+        register("koin") {
+            id = "convention.koin"
+            implementationClass = "KoinPlugin"
+        }
+    }
+}
+```
+
+### KmpLibraryPlugin.kt
+
+```kotlin
+import com.android.build.gradle.LibraryExtension
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.getByType
+import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
+
+class KmpLibraryPlugin : Plugin<Project> {
+    override fun apply(target: Project) {
+        with(target) {
+            with(pluginManager) {
+                apply("org.jetbrains.kotlin.multiplatform")
+                apply("com.android.library")
+            }
+
+            extensions.configure<LibraryExtension> {
+                compileSdk = 35
+                defaultConfig {
+                    minSdk = 26
+                }
+                compileOptions {
+                    sourceCompatibility = JavaVersion.VERSION_17
+                    targetCompatibility = JavaVersion.VERSION_17
+                }
+            }
+
+            extensions.configure<KotlinMultiplatformExtension> {
+                androidTarget {
+                    compilations.all {
+                        compileTaskProvider.configure {
+                            compilerOptions {
+                                jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+                            }
+                        }
+                    }
+                }
+
+                listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { target ->
+                    target.binaries.framework {
+                        baseName = project.name
+                        isStatic = true
+                    }
+                }
+
+                sourceSets.commonMain.dependencies {
+                    implementation(libs.findLibrary("kotlinx-coroutines-core").get())
+                }
+            }
+        }
+    }
+}
+```
+
+### KmpLibraryComposePlugin.kt
+
+```kotlin
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import org.jetbrains.compose.ComposeExtension
+import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
+
+class KmpLibraryComposePlugin : Plugin<Project> {
+    override fun apply(target: Project) {
+        with(target) {
+            pluginManager.apply("convention.kmp.library")
+            pluginManager.apply("org.jetbrains.compose")
+            pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
+
+            extensions.configure<KotlinMultiplatformExtension> {
+                sourceSets.commonMain.dependencies {
+                    val compose = extensions.getByType<ComposeExtension>().dependencies
+                    implementation(compose.runtime)
+                    implementation(compose.foundation)
+                    implementation(compose.material3)
+                    implementation(compose.ui)
+                    implementation(compose.components.resources)
+                }
+            }
+        }
+    }
+}
+```
+
+### KoinPlugin.kt
+
+```kotlin
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
+
+class KoinPlugin : Plugin<Project> {
+    override fun apply(target: Project) {
+        with(target) {
+            extensions.configure<KotlinMultiplatformExtension> {
+                sourceSets.commonMain.dependencies {
+                    implementation(libs.findLibrary("koin-core").get())
+                    implementation(libs.findLibrary("koin-compose").get())
+                    implementation(libs.findLibrary("koin-compose-viewmodel").get())
+                }
+                sourceSets.named("androidMain") {
+                    dependencies {
+                        implementation(libs.findLibrary("koin-android").get())
+                    }
+                }
+            }
+        }
+    }
+}
+```
+
+### Using Convention Plugins in a Feature Module
+
+```kotlin
+// feature/home/build.gradle.kts
+plugins {
+    alias(libs.plugins.convention.kmp.library.compose)
+    alias(libs.plugins.convention.koin)
+    alias(libs.plugins.kotlin.serialization)
+}
+
+kotlin {
+    sourceSets {
+        commonMain.dependencies {
+            implementation(projects.core.domain)
+            implementation(projects.core.data)
+            implementation(libs.navigation.compose)
+        }
+    }
+}
+```
+
+---
+
+## KSP Configuration for Room
+
+```kotlin
+// shared/build.gradle.kts
+plugins {
+    alias(libs.plugins.ksp)
+    alias(libs.plugins.room)
+}
+
+kotlin {
+    sourceSets {
+        commonMain.dependencies {
+            implementation(libs.room.runtime)
+            implementation(libs.room.ktx)
+        }
+    }
+}
+
+// KSP — add Room processor for each platform target
+dependencies {
+    add("kspAndroid", libs.room.compiler)
+    add("kspIosX64", libs.room.compiler)
+    add("kspIosArm64", libs.room.compiler)
+    add("kspIosSimulatorArm64", libs.room.compiler)
+}
+
+// Room — schema output directory for migration validation
+room {
+    schemaDirectory("$projectDir/schemas")
+}
+```
+
+Enable incremental processing in `gradle.properties`:
+
+```properties
+ksp.incremental=true
+```
+
+---
+
+## Version Catalog — Build Plugin Dependencies
+
+Add build plugin deps to the catalog so convention plugins can use `libs`:
+
+```toml
+# gradle/libs.versions.toml
+[libraries]
+android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
+kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
+compose-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" }
+ksp-gradlePlugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" }
+
+[plugins]
+convention-kmp-library = { id = "convention.kmp.library", version = "unspecified" }
+convention-kmp-library-compose = { id = "convention.kmp.library.compose", version = "unspecified" }
+convention-android-application = { id = "convention.android.application", version = "unspecified" }
+convention-koin = { id = "convention.koin", version = "unspecified" }
+```
+
+---
+
+## Settings.gradle.kts (Multi-Module)
+
+```kotlin
+// settings.gradle.kts
+pluginManagement {
+    includeBuild("build-logic")   // include convention plugins
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "MyApp"
+
+// Feature modules
+include(":app")
+include(":iosApp")
+include(":shared")
+include(":core:domain")
+include(":core:data")
+include(":core:database")
+include(":core:network")
+include(":core:designsystem")
+include(":feature:home:api")
+include(":feature:home:impl")
+include(":feature:auth:api")
+include(":feature:auth:impl")
+```
+
+---
+
+## BuildKonfig — Full Configuration
+
+```kotlin
+// shared/build.gradle.kts
+buildkonfig {
+    packageName = "com.example.shared"
+
+    defaultConfigs {
+        buildConfigField(STRING, "ENVIRONMENT", "staging")
+        buildConfigField(BOOLEAN, "IS_DEBUG", "true")
+        buildConfigField(BOOLEAN, "ENABLE_LOGGING", "true")
+        buildConfigField(STRING, "API_BASE_URL", "https://api.staging.example.com")
+        buildConfigField(STRING, "APP_VERSION", project.version.toString())
+    }
+
+    // Production overrides
+    targetConfigs("prod") {
+        buildConfigField(STRING, "ENVIRONMENT", "production")
+        buildConfigField(BOOLEAN, "IS_DEBUG", "false")
+        buildConfigField(BOOLEAN, "ENABLE_LOGGING", "false")
+        buildConfigField(STRING, "API_BASE_URL", "https://api.example.com")
+    }
+}
+```
+
+Access from Swift:
+
+```swift
+import shared
+
+let apiUrl = BuildKonfig.shared.API_BASE_URL
+let isDebug = BuildKonfig.shared.IS_DEBUG
+```
+
+Access from Kotlin:
+
+```kotlin
+val apiUrl = BuildKonfig.API_BASE_URL
+val isDebug = BuildKonfig.IS_DEBUG
+```
+
+---
+
+## R8 / ProGuard Configuration (Android)
+
+Keep rules are required for Ktor, Room, and Koin in release builds. Add to `android/proguard-rules.pro`:
+
+```proguard
+# Ktor — keep serialization metadata
+-keep class io.ktor.** { *; }
+-keep class kotlinx.serialization.** { *; }
+-keepattributes *Annotation*, InnerClasses
+-dontnote kotlinx.serialization.AnnotationsKt
+-keepclassmembers class ** {
+    @kotlinx.serialization.Serializable *;
+}
+
+# Room — keep generated _Impl classes
+-keep class * extends androidx.room.RoomDatabase
+-keep @androidx.room.Entity class *
+-dontwarn androidx.room.paging.**
+
+# Koin — keep module declarations
+-keep class org.koin.** { *; }
+-keepnames class * extends org.koin.core.module.Module
+
+# Kotlin reflection (used by Koin)
+-keep class kotlin.Metadata { *; }
+-keepclassmembers class ** {
+    @kotlin.jvm.JvmField *;
+    @kotlin.jvm.JvmStatic *;
+}
+
+# DataStore
+-keep class androidx.datastore.** { *; }
+
+# Coroutines
+-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
+-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
+```
+
+Enable R8 full mode for better size reduction (requires more explicit keeps):
+
+```kotlin
+// android/build.gradle.kts
+android {
+    buildTypes {
+        release {
+            isMinifyEnabled = true
+            isShrinkResources = true
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+    }
+}
+```
+
+```properties
+# gradle.properties — enable R8 full mode
+android.enableR8.fullMode=true
+```
+
+---
+
+## Publishing to Maven (Local and Remote)
+
+### Local Maven (for local multi-repo development)
+
+```kotlin
+// shared/build.gradle.kts
+plugins {
+    id("maven-publish")
+}
+
+afterEvaluate {
+    publishing {
+        publications {
+            create<MavenPublication>("release") {
+                groupId = "com.example"
+                artifactId = "shared"
+                version = "1.0.0"
+                from(components["kotlin"])
+            }
+        }
+        repositories {
+            maven {
+                name = "LocalMaven"
+                url = uri("${rootProject.buildDir}/local-maven")
+            }
+        }
+    }
+}
+```
+
+```bash
+# Publish to local Maven repo
+./gradlew :shared:publishReleasePublicationToLocalMavenRepository
+
+# Consume from local Maven in another project
+repositories {
+    maven { url = uri("/path/to/local-maven") }
+}
+```
+
+### Publishing to GitHub Packages (CI)
+
+```kotlin
+// shared/build.gradle.kts
+publishing {
+    repositories {
+        maven {
+            name = "GitHubPackages"
+            url = uri("https://maven.pkg.github.com/ORG/REPO")
+            credentials {
+                username = System.getenv("GITHUB_ACTOR")
+                password = System.getenv("GITHUB_TOKEN")
+            }
+        }
+    }
+}
+```
+
+```yaml
+# .github/workflows/publish.yml
+- name: Publish to GitHub Packages
+  run: ./gradlew :shared:publish
+  env:
+    GITHUB_ACTOR: ${{ github.actor }}
+    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+```
+
+---
+
+## CI Gradle Daemon Configuration
+
+Disable the Gradle daemon in CI to avoid warm-up overhead and avoid orphaned daemon processes:
+
+```properties
+# gradle.properties (CI override via environment or -P flag)
+# In CI, set via: ./gradlew -Dorg.gradle.daemon=false
+org.gradle.daemon=false
+```
+
+Or set in CI workflow:
+
+```yaml
+# GitHub Actions — disable daemon and configure memory for CI
+env:
+  GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dkotlin.incremental=false -Dorg.gradle.jvmargs=-Xmx4g"
+```
+
+Use Gradle's built-in caching in GitHub Actions:
+
+```yaml
+- name: Setup Gradle
+  uses: gradle/actions/setup-gradle@v4
+  with:
+    cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+
+- name: Build
+  run: ./gradlew :shared:assembleRelease
+```
+
+This caches the Gradle home directory, build cache, and configuration cache between runs — significantly speeds up CI.
+
+---
+
+## Build Performance Tips
+
+1. **Enable build cache** — `org.gradle.caching=true` in `gradle.properties`
+2. **Enable configuration cache** — `org.gradle.configuration-cache=true`
+3. **Parallel builds** — `org.gradle.parallel=true`
+4. **Avoid `implementation` in `api`** — Use `api()` only for types exposed in public signatures
+5. **Use `compileOnly`** for annotation processors that don't need to be on runtime classpath
+6. **Prefer `testImplementation`** over `implementation` for test-only deps
+7. **Disable daemon in CI** — `org.gradle.daemon=false` via `GRADLE_OPTS` environment variable
+8. **Use `--no-configuration-cache` selectively** — some third-party plugins are not configuration-cache compatible yet; add them to the incompatible task list rather than disabling globally
+
+Check build health with:
+```bash
+./gradlew buildHealth          # dependency analysis
+./gradlew :shared:dependencies # inspect dependency tree
+./gradlew --profile            # generate HTML build performance report
+```

+ 653 - 0
.claude/skills/kmp-compose-multiplatform/references/compose-best-practices.md

@@ -0,0 +1,653 @@
+# Compose Multiplatform Best Practices
+
+References: [Compose Architecture](https://developer.android.com/develop/ui/compose/architecture) | [Compose State](https://developer.android.com/develop/ui/compose/state) | [Compose Layouts](https://developer.android.com/develop/ui/compose/layouts) | [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/)
+
+---
+
+## Composable Design Principles
+
+### 1. Keep Composables Stateless
+
+Composables should receive state and emit events — never hold state internally unless it is purely visual:
+
+```kotlin
+// GOOD — stateless, testable, reusable
+@Composable
+fun UserCard(
+    name: String,
+    avatarUrl: String,
+    onCardClick: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    Card(modifier = modifier.clickable(onClick = onCardClick)) {
+        // ...
+    }
+}
+
+// BAD — holds business state internally
+@Composable
+fun UserCard(userId: String) {
+    val user by remember { mutableStateOf<User?>(null) }
+    // fetching in composable is an anti-pattern
+}
+```
+
+### 2. Always Accept a Modifier Parameter
+
+Every composable must accept `modifier: Modifier = Modifier` as the last parameter before lambda parameters:
+
+```kotlin
+@Composable
+fun PrimaryButton(
+    text: String,
+    onClick: () -> Unit,
+    enabled: Boolean = true,
+    modifier: Modifier = Modifier  // always last before lambdas
+) {
+    Button(
+        onClick = onClick,
+        enabled = enabled,
+        modifier = modifier  // applied to the root element
+    ) {
+        Text(text = text)
+    }
+}
+```
+
+### 3. Extract Large Composables
+
+Break large composables into smaller, focused functions:
+
+```kotlin
+// GOOD — clear structure
+@Composable
+fun HomeScreen(uiState: HomeUiState, onAction: (HomeAction) -> Unit) {
+    Column {
+        HomeHeader(uiState.title)
+        HomeContent(uiState.items, onAction)
+        HomeFooter(onAction)
+    }
+}
+
+// BAD — one giant composable
+@Composable
+fun HomeScreen(uiState: HomeUiState, onAction: (HomeAction) -> Unit) {
+    Column {
+        // 200 lines of UI...
+    }
+}
+```
+
+### 4. Use Stable Parameters and @Stable
+
+The Compose compiler marks a type as **stable** if it can guarantee that `equals()` is reliable and public properties only change when `equals()` returns `false`. Unstable parameters cause unnecessary recomposition.
+
+**Stable by default:** primitives, `String`, lambdas, `data class` with all-stable properties.
+
+**Unstable by default:** `List`, `Map`, `Set` (use `kotlinx.collections.immutable` for stable collections), classes with `var` properties, abstract classes/interfaces.
+
+```kotlin
+// GOOD — immutable list, stable
+@Composable
+fun ItemList(items: List<Item>, modifier: Modifier = Modifier) { ... }
+
+// BAD — mutable list parameter
+@Composable
+fun ItemList(items: MutableList<Item>, modifier: Modifier = Modifier) { ... }
+```
+
+Mark custom classes that the compiler can't infer as stable:
+
+```kotlin
+@Stable
+data class HomeUiState(
+    val isLoading: Boolean = false,
+    val items: List<Item> = emptyList(),
+    val errorMessage: String? = null
+)
+
+// For interfaces used as composable parameters
+@Stable
+interface ItemActions {
+    fun onItemClick(id: String)
+    fun onItemDelete(id: String)
+}
+```
+
+Use `derivedStateOf` for values computed from other state — prevents recomposition when the derived value hasn't changed:
+
+```kotlin
+// GOOD — only recomposes when isButtonEnabled actually changes
+val isButtonEnabled by remember {
+    derivedStateOf { uiState.name.isNotBlank() && uiState.email.isNotBlank() }
+}
+
+// BAD — recomputes inline on every parent recomposition
+val isButtonEnabled = uiState.name.isNotBlank() && uiState.email.isNotBlank()
+```
+
+---
+
+## State Management
+
+### State Hoisting Pattern
+
+Move state up to the lowest common ancestor that needs it:
+
+```kotlin
+// Screen level — owns the state
+@Composable
+fun SearchScreen(viewModel: SearchViewModel = koinViewModel()) {
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    SearchContent(
+        query = uiState.query,
+        results = uiState.results,
+        onQueryChange = viewModel::onQueryChange,
+        onSearch = viewModel::onSearch
+    )
+}
+
+// Content level — stateless, just renders
+@Composable
+fun SearchContent(
+    query: String,
+    results: List<SearchResult>,
+    onQueryChange: (String) -> Unit,
+    onSearch: () -> Unit,
+    modifier: Modifier = Modifier
+) { ... }
+```
+
+### When to Use remember vs StateFlow
+
+| Scenario | Use |
+|----------|-----|
+| UI-only transient state (expanded, selected tab) | `remember { mutableStateOf(...) }` |
+| Business state | `ViewModel + StateFlow` |
+| Animation state | `remember { Animatable(...) }` |
+| Scroll position | `rememberLazyListState()` |
+
+### Avoiding rememberCoroutineScope Anti-Patterns
+
+```kotlin
+// BAD — launching coroutines from composable
+@Composable
+fun MyScreen() {
+    val scope = rememberCoroutineScope()
+    Button(onClick = { scope.launch { fetchData() } }) { ... }
+}
+
+// GOOD — delegate to ViewModel
+@Composable
+fun MyScreen(viewModel: MyViewModel = koinViewModel()) {
+    Button(onClick = viewModel::onLoadData) { ... }
+}
+```
+
+---
+
+## Material 3 Guidelines
+
+### Theme Setup
+
+```kotlin
+// AppTheme.kt
+@Composable
+fun AppTheme(
+    darkTheme: Boolean = isSystemInDarkTheme(),
+    content: @Composable () -> Unit
+) {
+    val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
+
+    MaterialTheme(
+        colorScheme = colorScheme,
+        typography = AppTypography,
+        content = content
+    )
+}
+
+private val DarkColorScheme = darkColorScheme(
+    primary = ColorPalette.Primary,
+    secondary = ColorPalette.Secondary,
+    background = ColorPalette.BackgroundDark,
+    surface = ColorPalette.SurfaceDark,
+    onPrimary = Color.White,
+    onBackground = ColorPalette.OnBackgroundDark
+)
+```
+
+### Color Usage
+
+```kotlin
+// GOOD — use MaterialTheme tokens
+Text(
+    text = "Hello",
+    color = MaterialTheme.colorScheme.onBackground
+)
+
+// BAD — hardcoded colors
+Text(
+    text = "Hello",
+    color = Color(0xFF1A1A1A)
+)
+```
+
+### Typography Usage
+
+```kotlin
+// GOOD — use typography scale
+Text(text = "Title", style = MaterialTheme.typography.headlineMedium)
+Text(text = "Body", style = MaterialTheme.typography.bodyLarge)
+
+// BAD — hardcoded text style
+Text(text = "Title", fontSize = 24.sp, fontWeight = FontWeight.Bold)
+```
+
+---
+
+## Layouts
+
+### Adaptive Layouts
+
+Support different screen sizes:
+
+```kotlin
+@Composable
+fun AdaptiveHomeScreen(
+    uiState: HomeUiState,
+    windowSizeClass: WindowSizeClass,
+    modifier: Modifier = Modifier
+) {
+    when (windowSizeClass.widthSizeClass) {
+        WindowWidthSizeClass.Compact -> HomeCompact(uiState, modifier)
+        WindowWidthSizeClass.Medium -> HomeMedium(uiState, modifier)
+        WindowWidthSizeClass.Expanded -> HomeExpanded(uiState, modifier)
+    }
+}
+```
+
+### Lazy Lists
+
+```kotlin
+// Prefer LazyColumn/LazyRow for long lists
+LazyColumn(
+    contentPadding = PaddingValues(16.dp),
+    verticalArrangement = Arrangement.spacedBy(8.dp)
+) {
+    items(
+        items = uiState.items,
+        key = { item -> item.id }  // always provide stable keys
+    ) { item ->
+        ItemCard(item = item)
+    }
+}
+```
+
+---
+
+## Resources
+
+### String Resources (Compose Multiplatform)
+
+Define strings in `commonMain/composeResources/values/strings.xml`:
+
+```xml
+<resources>
+    <string name="app_name">My App</string>
+    <string name="home_title">Welcome, %1$s</string>
+</resources>
+```
+
+Use in composables:
+
+```kotlin
+import org.jetbrains.compose.resources.stringResource
+import com.example.shared.generated.resources.Res
+import com.example.shared.generated.resources.app_name
+import com.example.shared.generated.resources.home_title
+
+@Composable
+fun HomeTitle(userName: String) {
+    Text(text = stringResource(Res.string.home_title, userName))
+}
+```
+
+### Image Resources
+
+```kotlin
+// In composable
+Image(
+    painter = painterResource(Res.drawable.logo),
+    contentDescription = null
+)
+```
+
+---
+
+## Accessibility
+
+Every composable that conveys meaning must be accessible. The Compose accessibility tree is read by TalkBack (Android) and VoiceOver (iOS).
+
+### Content Descriptions
+
+Always provide `contentDescription` for images and icon-only buttons. Use `null` only for purely decorative content:
+
+```kotlin
+// GOOD — meaningful image
+Image(
+    painter = painterResource(Res.drawable.avatar),
+    contentDescription = stringResource(Res.string.user_avatar_description)
+)
+
+// GOOD — decorative image
+Image(
+    painter = painterResource(Res.drawable.background_pattern),
+    contentDescription = null  // explicitly decorative
+)
+
+// GOOD — icon button with description
+IconButton(onClick = onFavoriteClick) {
+    Icon(
+        imageVector = Icons.Default.Favorite,
+        contentDescription = stringResource(Res.string.add_to_favorites)
+    )
+}
+```
+
+### Semantics Modifiers
+
+Use `Modifier.semantics` to provide accessibility metadata beyond what Compose infers:
+
+```kotlin
+// Merge child semantics into a single accessible node
+Card(
+    modifier = Modifier
+        .semantics(mergeDescendants = true) {}
+        .clickable(onClick = onClick)
+) {
+    Text(text = item.title)
+    Text(text = item.subtitle)
+}
+
+// Custom action for complex interactions
+Box(
+    modifier = Modifier.semantics {
+        contentDescription = "Item: ${item.title}"
+        onClick(label = "Open detail") {
+            onItemClick(item.id)
+            true
+        }
+    }
+)
+
+// State descriptions (e.g., toggle buttons)
+val toggleDescription = if (isSelected) "Selected" else "Not selected"
+FilterChip(
+    selected = isSelected,
+    onClick = onToggle,
+    label = { Text(item.label) },
+    modifier = Modifier.semantics {
+        stateDescription = toggleDescription
+    }
+)
+```
+
+### `clearAndSetSemantics` — Override Inferred Semantics
+
+Use when the default accessibility tree is noisy or misleading:
+
+```kotlin
+// Replace all child semantics with a single clean description
+Row(
+    modifier = Modifier.clearAndSetSemantics {
+        contentDescription = "${user.name}, ${user.role}, ${if (user.isOnline) "Online" else "Offline"}"
+    }
+) {
+    Avatar(user = user)
+    Column {
+        Text(user.name)
+        Text(user.role)
+    }
+    OnlineIndicator(isOnline = user.isOnline)
+}
+```
+
+### Minimum Touch Target Size
+
+Material 3 enforces 48dp minimum touch targets. For smaller visual elements, use padding to expand the touch area:
+
+```kotlin
+Icon(
+    imageVector = Icons.Default.Close,
+    contentDescription = "Dismiss",
+    modifier = Modifier
+        .size(24.dp)
+        .padding(12.dp)  // expands touch target to 48dp
+        .clickable(onClick = onDismiss)
+)
+```
+
+### Heading Semantics
+
+Mark section headers so screen readers can navigate by heading:
+
+```kotlin
+Text(
+    text = "Recent Activity",
+    style = MaterialTheme.typography.titleMedium,
+    modifier = Modifier.semantics { heading() }
+)
+```
+
+---
+
+## Focus Management
+
+### FocusRequester — Programmatic Focus
+
+Use `FocusRequester` to direct keyboard focus to a field automatically (e.g., on screen open or after form error):
+
+```kotlin
+@Composable
+fun LoginForm(onSubmit: (String, String) -> Unit) {
+    val emailFocusRequester = remember { FocusRequester() }
+    val passwordFocusRequester = remember { FocusRequester() }
+    val focusManager = LocalFocusManager.current
+
+    var email by remember { mutableStateOf("") }
+    var password by remember { mutableStateOf("") }
+
+    Column {
+        OutlinedTextField(
+            value = email,
+            onValueChange = { email = it },
+            label = { Text("Email") },
+            keyboardOptions = KeyboardOptions(
+                keyboardType = KeyboardType.Email,
+                imeAction = ImeAction.Next
+            ),
+            keyboardActions = KeyboardActions(
+                onNext = { passwordFocusRequester.requestFocus() }
+            ),
+            modifier = Modifier.focusRequester(emailFocusRequester)
+        )
+
+        OutlinedTextField(
+            value = password,
+            onValueChange = { password = it },
+            label = { Text("Password") },
+            visualTransformation = PasswordVisualTransformation(),
+            keyboardOptions = KeyboardOptions(
+                keyboardType = KeyboardType.Password,
+                imeAction = ImeAction.Done
+            ),
+            keyboardActions = KeyboardActions(
+                onDone = {
+                    focusManager.clearFocus()
+                    onSubmit(email, password)
+                }
+            ),
+            modifier = Modifier.focusRequester(passwordFocusRequester)
+        )
+    }
+
+    // Auto-focus email on screen open
+    LaunchedEffect(Unit) {
+        emailFocusRequester.requestFocus()
+    }
+}
+```
+
+### Text Field Accessibility
+
+Every `TextField`/`OutlinedTextField` must have:
+- A visible `label` OR a `semantics { contentDescription }` for screen readers
+- Correct `keyboardType` so the right keyboard appears (do not use `Text` keyboard for emails)
+- `imeAction` matching the field's role (`Next` for multi-field forms, `Done`/`Search` for last field)
+- Error state announced to accessibility services via `isError` + `supportingText`:
+
+```kotlin
+OutlinedTextField(
+    value = email,
+    onValueChange = { email = it },
+    label = { Text("Email address") },
+    isError = emailError != null,
+    supportingText = emailError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
+    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
+    modifier = Modifier
+        .fillMaxWidth()
+        .semantics {
+            if (emailError != null) error(emailError)  // announces error to TalkBack
+        }
+)
+```
+
+### Focus Order
+
+Override the default focus traversal order when the visual layout doesn't match the logical reading order:
+
+```kotlin
+// Explicit focus order for a custom layout
+Box {
+    TextField(
+        modifier = Modifier.focusProperties { next = secondFieldFocusRequester }
+    )
+    TextField(
+        modifier = Modifier.focusProperties { previous = firstFieldFocusRequester }
+    )
+}
+```
+
+---
+
+## Dynamic Type / Font Scaling
+
+Do not hardcode `sp` sizes that break at large text scale settings. Allow the system font scale to apply:
+
+```kotlin
+// GOOD — uses TextUnit.Unspecified so MaterialTheme scale applies
+Text(
+    text = content,
+    style = MaterialTheme.typography.bodyLarge  // scales with system font size
+)
+
+// BAD — ignores user's accessibility font size preference
+Text(
+    text = content,
+    fontSize = 16.sp,   // fixed size, still scales with SP by default
+    lineHeight = 16.sp  // hardcoded line height prevents proper scaling
+)
+```
+
+Test for large font scales:
+```kotlin
+// Set font scale in Compose preview
+@Preview(fontScale = 1.5f)
+@Composable
+private fun HomeScreenPreview_LargeFont() {
+    AppTheme { HomeContent(uiState = HomeUiState(), onAction = {}) }
+}
+
+@Preview(fontScale = 2.0f)
+@Composable
+private fun HomeScreenPreview_ExtraLargeFont() {
+    AppTheme { HomeContent(uiState = HomeUiState(), onAction = {}) }
+}
+```
+
+For containers that must not expand with text (e.g., a fixed-height chip), use `nonScaledSp`:
+
+```kotlin
+// Convert dp → sp without scaling — use sparingly, only for layout-critical sizes
+val nonScaledTextSize = with(LocalDensity.current) { 12.dp.toSp() }
+```
+
+---
+
+## Performance
+
+### Key Performance Rules
+
+1. **Avoid unnecessary recomposition** — use `remember`, `derivedStateOf`, and `key` parameters
+2. **Use `key()` in loops** — stable keys prevent full list recomposition
+3. **Avoid heavy work in composition** — use `LaunchedEffect` or ViewModel for side effects
+4. **Use `derivedStateOf`** for computed state that depends on other state:
+
+```kotlin
+val isButtonEnabled by remember {
+    derivedStateOf { uiState.name.isNotBlank() && uiState.email.isNotBlank() }
+}
+```
+
+5. **Profile with Layout Inspector** — identify recomposition counts
+
+---
+
+## Previews
+
+Always provide at minimum: light + dark previews.
+
+```kotlin
+@Preview
+@Composable
+private fun HomeScreenPreview_Light() {
+    AppTheme(darkTheme = false) {
+        HomeContent(
+            uiState = HomeUiState(
+                isLoading = false,
+                title = "Preview Title",
+                items = PreviewData.items
+            ),
+            onAction = {}
+        )
+    }
+}
+
+@Preview
+@Composable
+private fun HomeScreenPreview_Dark() {
+    AppTheme(darkTheme = true) {
+        HomeContent(
+            uiState = HomeUiState.Loading,
+            onAction = {}
+        )
+    }
+}
+
+// Preview data object (never in production code)
+private object PreviewData {
+    val items = List(5) { Item(id = "$it", title = "Item $it") }
+}
+```
+
+---
+
+## Official Resources
+
+- [Compose Architecture](https://developer.android.com/develop/ui/compose/architecture)
+- [Compose State Guide](https://developer.android.com/develop/ui/compose/state)
+- [Compose Layouts](https://developer.android.com/develop/ui/compose/layouts)
+- [Compose Performance](https://developer.android.com/develop/ui/compose/performance)
+- [Compose Multiplatform Resources](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-resources-usage.html)
+- [Material 3 Design](https://m3.material.io/)
+- [Material 3 in Compose](https://developer.android.com/develop/ui/compose/designsystems/material3)

+ 386 - 0
.claude/skills/kmp-compose-multiplatform/references/error-handling.md

@@ -0,0 +1,386 @@
+# Error Handling — KMP + Compose Multiplatform
+
+---
+
+## Domain Error Types
+
+Never use raw `String` for errors. Define a structured domain error hierarchy:
+
+```kotlin
+// core/util/AppError.kt — commonMain
+sealed class AppError : Throwable() {
+
+    // Network errors
+    sealed class Network : AppError() {
+        data object NoConnection : Network()
+        data object Timeout : Network()
+        data class ServerError(val code: Int, val body: String?) : Network()
+        data class Unauthorized(val message: String = "Session expired") : Network()
+        data object Unknown : Network()
+    }
+
+    // Local persistence errors
+    sealed class Database : AppError() {
+        data class ReadFailure(override val cause: Throwable?) : Database()
+        data class WriteFailure(override val cause: Throwable?) : Database()
+        data object NotFound : Database()
+    }
+
+    // Business/domain validation errors
+    sealed class Validation : AppError() {
+        data class InvalidInput(val field: String, val reason: String) : Validation()
+        data object RequiredFieldMissing : Validation()
+    }
+
+    // Unknown/unexpected
+    data class Unexpected(override val cause: Throwable?) : AppError()
+}
+```
+
+---
+
+## Resource Sealed Class (with typed errors)
+
+```kotlin
+// core/util/Resource.kt — commonMain
+sealed class Resource<out T> {
+    data class Success<out T>(val data: T) : Resource<T>()
+    data class Error(val error: AppError) : Resource<Nothing>()
+    data object Loading : Resource<Nothing>()
+}
+
+// Extension functions for ergonomic handling
+inline fun <T> Resource<T>.onSuccess(action: (T) -> Unit): Resource<T> {
+    if (this is Resource.Success) action(data)
+    return this
+}
+
+inline fun <T> Resource<T>.onError(action: (AppError) -> Unit): Resource<T> {
+    if (this is Resource.Error) action(error)
+    return this
+}
+
+inline fun <T> Resource<T>.onLoading(action: () -> Unit): Resource<T> {
+    if (this is Resource.Loading) action()
+    return this
+}
+
+inline fun <T, R> Resource<T>.map(transform: (T) -> R): Resource<R> = when (this) {
+    is Resource.Success -> Resource.Success(transform(data))
+    is Resource.Error -> this
+    is Resource.Loading -> this
+}
+```
+
+---
+
+## Ktor Error Mapping
+
+Map HTTP and network exceptions to domain errors at the data layer boundary — never let `ClientRequestException` or `IOException` escape into domain/presentation:
+
+```kotlin
+// core/network/NetworkErrorMapper.kt — commonMain
+import io.ktor.client.plugins.*
+import io.ktor.http.*
+
+suspend fun <T> safeApiCall(call: suspend () -> T): Resource<T> {
+    return try {
+        Resource.Success(call())
+    } catch (e: ClientRequestException) {
+        val error = when (e.response.status) {
+            HttpStatusCode.Unauthorized -> AppError.Network.Unauthorized()
+            HttpStatusCode.NotFound -> AppError.Database.NotFound
+            else -> AppError.Network.ServerError(
+                code = e.response.status.value,
+                body = e.response.toString()
+            )
+        }
+        Resource.Error(error)
+    } catch (e: ServerResponseException) {
+        Resource.Error(AppError.Network.ServerError(e.response.status.value, null))
+    } catch (e: HttpRequestTimeoutException) {
+        Resource.Error(AppError.Network.Timeout)
+    } catch (e: Exception) {
+        if (e.message?.contains("Unable to resolve host") == true ||
+            e.message?.contains("Network is unreachable") == true) {
+            Resource.Error(AppError.Network.NoConnection)
+        } else {
+            Resource.Error(AppError.Unexpected(e))
+        }
+    }
+}
+```
+
+Usage in repository:
+
+```kotlin
+class UserRepositoryImpl(private val api: UserApiService) : UserRepository {
+
+    override suspend fun getUser(id: String): Resource<User> =
+        safeApiCall { api.getUser(id) }.map { it.toDomain() }
+}
+```
+
+---
+
+## Room Error Mapping
+
+```kotlin
+// Wrap all database calls similarly
+suspend fun <T> safeDbCall(call: suspend () -> T): Resource<T> {
+    return try {
+        Resource.Success(call())
+    } catch (e: Exception) {
+        Resource.Error(AppError.Database.ReadFailure(e))
+    }
+}
+```
+
+---
+
+## Error Handling in ViewModel
+
+Map domain errors to user-facing messages at the presentation layer:
+
+```kotlin
+// Presentation layer — never expose AppError directly to the UI
+data class HomeUiState(
+    val isLoading: Boolean = false,
+    val items: List<Item> = emptyList(),
+    val errorMessage: String? = null   // human-readable, never AppError
+)
+
+class HomeViewModel(private val getItemsUseCase: GetItemsUseCase) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(HomeUiState())
+    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
+
+    fun loadItems() {
+        viewModelScope.launch {
+            _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+            getItemsUseCase().onSuccess { items ->
+                _uiState.update { it.copy(isLoading = false, items = items) }
+            }.onError { error ->
+                _uiState.update { it.copy(isLoading = false, errorMessage = error.toUserMessage()) }
+            }
+        }
+    }
+}
+
+// Extension — localize error strings here, not in the domain
+fun AppError.toUserMessage(): String = when (this) {
+    is AppError.Network.NoConnection -> "No internet connection. Please check your network."
+    is AppError.Network.Timeout -> "Request timed out. Please try again."
+    is AppError.Network.Unauthorized -> "Your session has expired. Please log in again."
+    is AppError.Network.ServerError -> "Server error ($code). Please try again later."
+    is AppError.Database.NotFound -> "Item not found."
+    is AppError.Validation.InvalidInput -> "Invalid $field: $reason"
+    else -> "Something went wrong. Please try again."
+}
+```
+
+---
+
+## Error Handling in Compose UI
+
+```kotlin
+@Composable
+fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) {
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+    HomeContent(
+        uiState = uiState,
+        onRetry = viewModel::loadItems
+    )
+}
+
+@Composable
+fun HomeContent(
+    uiState: HomeUiState,
+    onRetry: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    Box(modifier = modifier.fillMaxSize()) {
+        // Content
+        if (uiState.items.isNotEmpty()) {
+            ItemList(items = uiState.items)
+        }
+
+        // Loading overlay
+        if (uiState.isLoading) {
+            CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+        }
+
+        // Error state
+        uiState.errorMessage?.let { message ->
+            ErrorBanner(
+                message = message,
+                onRetry = onRetry,
+                modifier = Modifier.align(Alignment.BottomCenter)
+            )
+        }
+    }
+}
+```
+
+---
+
+## Recoverable vs Fatal Error Classification
+
+Not all errors are equal — classify them to drive the right UI response:
+
+```kotlin
+// Extend AppError with recoverability metadata
+val AppError.isRecoverable: Boolean get() = when (this) {
+    is AppError.Network.NoConnection -> true    // user can re-enable wifi
+    is AppError.Network.Timeout -> true         // user can retry
+    is AppError.Network.ServerError -> code in 500..599  // server-side, worth retrying
+    is AppError.Network.Unauthorized -> false   // must re-authenticate
+    is AppError.Database.ReadFailure -> false   // data corruption — escalate
+    is AppError.Database.WriteFailure -> true   // may succeed on retry
+    is AppError.Database.NotFound -> false      // no point retrying
+    is AppError.Validation -> false             // user input issue — don't retry automatically
+    is AppError.Unexpected -> false             // unknown — treat as fatal
+    else -> false
+}
+
+val AppError.isFatal: Boolean get() = !isRecoverable
+```
+
+Use `isFatal` to decide whether to show a retry button or navigate to an error screen:
+
+```kotlin
+fun AppError.toUiAction(): ErrorAction = when {
+    isRecoverable -> ErrorAction.ShowRetry
+    this is AppError.Network.Unauthorized -> ErrorAction.NavigateToLogin
+    isFatal -> ErrorAction.ShowFatalDialog
+    else -> ErrorAction.ShowRetry
+}
+
+sealed class ErrorAction {
+    data object ShowRetry : ErrorAction()
+    data object NavigateToLogin : ErrorAction()
+    data object ShowFatalDialog : ErrorAction()
+}
+```
+
+---
+
+## 429 Rate Limiting
+
+Handle `429 Too Many Requests` separately from other server errors — back off and retry after the `Retry-After` header:
+
+```kotlin
+suspend fun <T> safeApiCall(call: suspend () -> T): Resource<T> {
+    return try {
+        Resource.Success(call())
+    } catch (e: ClientRequestException) {
+        val error = when (e.response.status) {
+            HttpStatusCode.Unauthorized -> AppError.Network.Unauthorized()
+            HttpStatusCode.TooManyRequests -> {
+                // Respect Retry-After header if present
+                val retryAfter = e.response.headers["Retry-After"]?.toLongOrNull() ?: 60L
+                delay(retryAfter * 1000L)
+                return safeApiCall(call)  // single retry after back-off
+            }
+            HttpStatusCode.NotFound -> AppError.Database.NotFound
+            else -> AppError.Network.ServerError(e.response.status.value, e.response.toString())
+        }
+        Resource.Error(error)
+    }
+    // ... other catch blocks
+}
+```
+
+In Ktor `HttpRequestRetry`, exclude 429 from default server-error retry (handle it manually above):
+
+```kotlin
+install(HttpRequestRetry) {
+    retryIf(maxRetries = 3) { _, response ->
+        response.status.value in 500..599 && response.status != HttpStatusCode.TooManyRequests
+    }
+    exponentialDelay(base = 2.0, maxDelayMs = 10_000, randomizationMs = 500)
+}
+```
+
+---
+
+## Error Analytics and Breadcrumbs
+
+Record non-fatal errors and add contextual breadcrumbs before sending to crash services:
+
+```kotlin
+interface ErrorReporter {
+    fun recordError(error: AppError, context: Map<String, String> = emptyMap())
+    fun addBreadcrumb(message: String, category: String = "app")
+}
+
+// androidMain
+class FirebaseErrorReporter : ErrorReporter {
+    private val crashlytics = FirebaseCrashlytics.getInstance()
+
+    override fun recordError(error: AppError, context: Map<String, String>) {
+        // Attach context as custom keys — visible in Crashlytics dashboard
+        context.forEach { (key, value) -> crashlytics.setCustomKey(key, value) }
+        crashlytics.setCustomKey("error_type", error::class.simpleName ?: "Unknown")
+        if (error is AppError.Network.ServerError) {
+            crashlytics.setCustomKey("http_code", error.code)
+        }
+        if (error.isFatal) {
+            crashlytics.recordException(error)
+        } else {
+            crashlytics.log("Non-fatal error: ${error::class.simpleName}")
+        }
+    }
+
+    override fun addBreadcrumb(message: String, category: String) {
+        crashlytics.log("[$category] $message")
+    }
+}
+```
+
+Use in the ViewModel — record before mapping to user message:
+
+```kotlin
+fun loadItems() {
+    viewModelScope.launch {
+        errorReporter.addBreadcrumb("Loading items", "home")
+        getItemsUseCase().onError { error ->
+            errorReporter.recordError(error, mapOf(
+                "screen" to "home",
+                "action" to "loadItems"
+            ))
+            _uiState.update { it.copy(errorMessage = error.toUserMessage()) }
+        }
+    }
+}
+```
+
+---
+
+## Retry Logic
+
+For use cases that should support retry (e.g., network-dependent operations):
+
+```kotlin
+// domain/util/RetryPolicy.kt — commonMain
+suspend fun <T> withRetry(
+    times: Int = 3,
+    initialDelay: Long = 1000L,
+    factor: Double = 2.0,
+    block: suspend () -> Resource<T>
+): Resource<T> {
+    var currentDelay = initialDelay
+    repeat(times - 1) {
+        val result = block()
+        if (result is Resource.Success || result is Resource.Error &&
+            result.error !is AppError.Network.NoConnection &&
+            result.error !is AppError.Network.Timeout) {
+            return result
+        }
+        delay(currentDelay)
+        currentDelay = (currentDelay * factor).toLong()
+    }
+    return block()
+}
+```

+ 285 - 0
.claude/skills/kmp-compose-multiplatform/references/i18n.md

@@ -0,0 +1,285 @@
+# Internationalization (i18n) — KMP + Compose Multiplatform
+
+References: [Compose Multiplatform Resources](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-resources-usage.html) | [Android Localization](https://developer.android.com/guide/topics/resources/localization)
+
+---
+
+## String Resources
+
+Define all user-facing strings in `commonMain/composeResources/values/strings.xml`. Never hardcode strings in Composables:
+
+```xml
+<!-- commonMain/composeResources/values/strings.xml -->
+<resources>
+    <string name="app_name">My App</string>
+    <string name="home_title">Welcome, %1$s</string>
+    <string name="items_count">%1$d items found</string>
+    <string name="error_no_connection">No internet connection. Please check your network.</string>
+    <string name="action_retry">Retry</string>
+    <string name="action_cancel">Cancel</string>
+    <string name="content_desc_loading">Loading</string>
+    <string name="content_desc_close">Close</string>
+</resources>
+```
+
+Add locale-specific overrides in separate directories:
+
+```
+commonMain/composeResources/
+├── values/
+│   └── strings.xml          ← default (English)
+├── values-es/
+│   └── strings.xml          ← Spanish
+├── values-fr/
+│   └── strings.xml          ← French
+├── values-ar/
+│   └── strings.xml          ← Arabic (RTL)
+└── values-ja/
+    └── strings.xml          ← Japanese
+```
+
+Use `stringResource()` in Composables — never access raw string IDs:
+
+```kotlin
+import org.jetbrains.compose.resources.stringResource
+import com.example.shared.generated.resources.Res
+import com.example.shared.generated.resources.*
+
+@Composable
+fun WelcomeHeader(userName: String) {
+    Text(text = stringResource(Res.string.home_title, userName))
+}
+
+@Composable
+fun ErrorMessage(onRetry: () -> Unit) {
+    Column {
+        Text(text = stringResource(Res.string.error_no_connection))
+        Button(onClick = onRetry) {
+            Text(text = stringResource(Res.string.action_retry))
+        }
+    }
+}
+```
+
+---
+
+## Plurals
+
+Use `pluralStringResource` for quantities that change grammatically:
+
+```xml
+<!-- commonMain/composeResources/values/strings.xml -->
+<resources>
+    <plurals name="items_count">
+        <item quantity="one">%1$d item</item>
+        <item quantity="other">%1$d items</item>
+    </plurals>
+
+    <plurals name="messages_unread">
+        <item quantity="zero">No unread messages</item>
+        <item quantity="one">%1$d unread message</item>
+        <item quantity="other">%1$d unread messages</item>
+    </plurals>
+</resources>
+```
+
+```kotlin
+import org.jetbrains.compose.resources.pluralStringResource
+
+@Composable
+fun ItemCount(count: Int) {
+    Text(text = pluralStringResource(Res.plurals.items_count, count, count))
+}
+```
+
+---
+
+## String Formatting
+
+Use positional arguments (`%1$s`, `%2$d`) — **never concatenate strings** because word order differs by language:
+
+```kotlin
+// GOOD — position-based, translatable
+Text(text = stringResource(Res.string.home_title, userName))  // "Welcome, Alice"
+
+// BAD — order is wrong in many languages
+Text(text = "Welcome, $userName")
+Text(text = stringResource(Res.string.welcome) + userName)
+```
+
+For complex formatting (dates, numbers, currency), use `kotlinx-datetime` + platform formatters:
+
+```kotlin
+// commonMain — locale-aware date formatting
+fun formatDate(date: LocalDate, locale: String = "en"): String {
+    // Format using ISO-8601 as a safe default
+    return "${date.month.name.lowercase().replaceFirstChar { it.uppercase() }} ${date.dayOfMonth}, ${date.year}"
+}
+```
+
+```kotlin
+// androidMain — use Android's DateFormat for locale-aware formatting
+actual fun formatDateLocalized(date: LocalDate): String {
+    val javaDate = java.util.Date.from(
+        date.atStartOfDayIn(TimeZone.currentSystemDefault()).toJavaInstant()
+    )
+    return android.text.format.DateFormat.getDateFormat(context).format(javaDate)
+}
+```
+
+---
+
+## RTL (Right-to-Left) Support
+
+Compose handles RTL automatically when the system locale is RTL (Arabic, Hebrew, Farsi). Key rules:
+
+### Use `start`/`end` instead of `left`/`right`
+
+```kotlin
+// GOOD — mirrors correctly in RTL
+Text(modifier = Modifier.padding(start = 16.dp, end = 8.dp))
+Row(horizontalArrangement = Arrangement.Start)
+Alignment.TopStart
+
+// BAD — does not mirror in RTL
+Text(modifier = Modifier.padding(left = 16.dp, right = 8.dp))
+Row(horizontalArrangement = Arrangement.AbsoluteLeft)
+Alignment.TopLeft
+```
+
+### Force LTR for non-localizable content (code, phone numbers, URLs)
+
+```kotlin
+Text(
+    text = phoneNumber,
+    modifier = Modifier.semantics { this.layoutDirection = LayoutDirection.Ltr }
+)
+```
+
+### Test RTL in Compose Preview
+
+```kotlin
+@Preview(locale = "ar")
+@Composable
+private fun HomeScreenPreview_RTL() {
+    AppTheme {
+        CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+            HomeContent(uiState = HomeUiState(), onAction = {})
+        }
+    }
+}
+```
+
+### BiDi-Aware Icons
+
+Use `AutoMirrored` icons for directional icons (arrows, back, forward) that should flip in RTL:
+
+```kotlin
+// GOOD — mirrors automatically in RTL
+Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
+
+// BAD — same direction in both LTR and RTL
+Icon(Icons.Default.ArrowBack, contentDescription = null)
+```
+
+---
+
+## Dynamic Locale Change
+
+Support changing the app language at runtime without restarting:
+
+```kotlin
+// androidMain — update locale via AppCompatDelegate
+actual fun setAppLocale(languageCode: String) {
+    AppCompatDelegate.setApplicationLocales(
+        LocaleListCompat.forLanguageTags(languageCode)
+    )
+    // Activity will be recreated automatically — Compose re-renders with new locale
+}
+```
+
+```xml
+<!-- AndroidManifest.xml — declare per-app language support -->
+<application>
+    <service
+        android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
+        android:enabled="false"
+        android:exported="false">
+        <meta-data
+            android:name="autoStoreLocales"
+            android:value="true" />
+    </service>
+</application>
+```
+
+Store the user's language preference in DataStore and apply on app startup:
+
+```kotlin
+class LocaleRepository(private val dataStore: DataStore<Preferences>) {
+    private val KEY_LANGUAGE = stringPreferencesKey("language_code")
+
+    val languageCode: Flow<String> = dataStore.data
+        .map { prefs -> prefs[KEY_LANGUAGE] ?: "system" }
+
+    suspend fun setLanguage(code: String) {
+        dataStore.edit { prefs -> prefs[KEY_LANGUAGE] = code }
+    }
+}
+```
+
+---
+
+## Non-Translatable Strings
+
+Mark strings that must not be translated (product names, trademarks) with `translatable="false"`:
+
+```xml
+<resources>
+    <string name="app_name" translatable="false">MyApp</string>
+    <string name="company_name" translatable="false">Acme Corp</string>
+</resources>
+```
+
+---
+
+## Locale-Aware Number and Currency Formatting
+
+Never format numbers or currencies by hand — use locale-aware formatters:
+
+```kotlin
+// androidMain
+actual fun formatCurrency(amount: Double, currencyCode: String): String {
+    val format = java.text.NumberFormat.getCurrencyInstance()
+    format.currency = java.util.Currency.getInstance(currencyCode)
+    return format.format(amount)
+}
+
+actual fun formatNumber(number: Double): String {
+    return java.text.NumberFormat.getNumberInstance().format(number)
+}
+```
+
+```kotlin
+// iosMain
+import platform.Foundation.*
+
+actual fun formatCurrency(amount: Double, currencyCode: String): String {
+    val formatter = NSNumberFormatter()
+    formatter.numberStyle = NSNumberFormatterCurrencyStyle
+    formatter.currencyCode = currencyCode
+    return formatter.stringFromNumber(NSNumber(double = amount)) ?: amount.toString()
+}
+```
+
+---
+
+## Key Rules
+
+- **Always use `stringResource()`** — never hardcode user-facing strings in Composables
+- **Use positional args** (`%1$s`) — never string concatenation for translatable content
+- **Use `start`/`end` padding and alignment** — never `left`/`right` for locale-mirroring
+- **Use `AutoMirrored` icons** for directional icons
+- **Test with RTL locale** (`ar`, `he`) and font scale 1.5x in every screen preview
+- **Mark non-translatable strings** with `translatable="false"` to prevent translator confusion
+- **Plurals for quantities** — never `if (count == 1) "item" else "items"`

+ 506 - 0
.claude/skills/kmp-compose-multiplatform/references/ios-interop.md

@@ -0,0 +1,506 @@
+# iOS / Swift Interop — KMP + Compose Multiplatform
+
+References: [KMP iOS Integration](https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-ios-integration-overview.html) | [Kotlin-Swift Interop](https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-ios-integration-overview.html)
+
+---
+
+## Kotlin → Swift Naming Conventions
+
+| Kotlin | Swift | Notes |
+|--------|-------|-------|
+| `class MyClass` | `MyClass` | Direct mapping |
+| Top-level `fun myFun()` | `MyClassKt.myFun()` | File name becomes prefix |
+| Top-level `val myVal` | `MyClassKt.myVal` | Same rule as functions |
+| `object MySingleton` | `MySingleton.shared` | Kotlin objects → `.shared` |
+| `companion object` | Direct on class | No `.companion` needed |
+| `suspend fun` | `async` (with KMP adapters) | Needs `@JvmStatic` or wrapper |
+| `sealed class` | Protocol + classes | Requires careful mapping |
+
+### Top-level function access example
+
+```kotlin
+// iosMain/MainViewController.kt
+fun MainViewController(): UIViewController = ComposeUIViewController { ... }
+```
+
+```swift
+// ContentView.swift — note the "Kt" suffix for top-level functions
+MainViewControllerKt.MainViewController()
+```
+
+---
+
+## Nullability Bridging
+
+Kotlin's null-safety maps directly to Swift optionals:
+
+| Kotlin | Swift |
+|--------|-------|
+| `String` (non-null) | `String` |
+| `String?` (nullable) | `String?` |
+| `List<String>` | `[String]` |
+| `List<String?>` | `[String?]` |
+
+**Important:** Kotlin's `Unit` becomes `KotlinUnit` in Swift. Avoid exposing functions returning `Unit` to Swift — use `Void` via wrapper functions when needed.
+
+```kotlin
+// Avoid this in public iOS API
+fun doSomething(): Unit { ... }
+
+// Prefer void-like callbacks wrapped in expect/actual or explicit wrappers
+```
+
+---
+
+## Collection Bridging
+
+Kotlin collections are bridged to Swift, but **mutability is lost**:
+
+```swift
+// Kotlin List<String> → Swift [String] (read-only copy)
+let items: [String] = kotlinObject.items as! [String]
+
+// Kotlin Map<String, Int> → Swift [String: Int]
+let map: [String: Int] = kotlinObject.config as! [String: Int]
+```
+
+Never pass Swift `Array` or `Dictionary` directly to Kotlin functions expecting `List` or `Map`. Wrap at the boundary:
+
+```kotlin
+// iosMain — expose iOS-friendly API
+fun processItems(items: Array<String>) {
+    internalFunction(items.toList())
+}
+```
+
+---
+
+## Coroutines & Swift Concurrency
+
+Kotlin `suspend` functions are **not automatically available** in Swift as `async`. Use one of these patterns:
+
+### Option 1: SKIE (recommended)
+
+[SKIE](https://skie.touchlab.co) auto-generates Swift-friendly async wrappers:
+
+```toml
+# libs.versions.toml
+skie = "0.9.5"
+
+[plugins]
+skie = { id = "co.touchlab.skie", version.ref = "skie" }
+```
+
+```kotlin
+// shared/build.gradle.kts
+plugins {
+    alias(libs.plugins.skie)
+}
+```
+
+With SKIE, Kotlin suspend functions become Swift async:
+
+```swift
+// Without SKIE — callback-based
+UserRepositoryKt.getUser(id: "1") { result, error in ... }
+
+// With SKIE — native Swift async/await
+let user = try await userRepository.getUser(id: "1")
+```
+
+### Option 2: Manual callback wrapper (no dependency)
+
+```kotlin
+// iosMain — wrap suspend functions in callbacks for Swift
+fun getUser(id: String, onSuccess: (User) -> Unit, onError: (String) -> Unit) {
+    CoroutineScope(Dispatchers.Main).launch {
+        when (val result = userRepository.getUser(id)) {
+            is Resource.Success -> onSuccess(result.data)
+            is Resource.Error -> onError(result.error.toUserMessage())
+            is Resource.Loading -> {}
+        }
+    }
+}
+```
+
+### Option 3: KMP-NativeCoroutines
+
+```toml
+kmp-nativecoroutines = "1.0.0-ALPHA-35"
+```
+
+Exposes Kotlin Flows and suspend functions as Swift async sequences.
+
+---
+
+## Flow Exposure to Swift
+
+Kotlin `Flow<T>` is **not directly usable** in Swift. Options:
+
+### With SKIE (recommended)
+SKIE converts `Flow<T>` to `AsyncSequence` automatically.
+
+```swift
+for await item in viewModel.uiState {
+    updateUI(with: item)
+}
+```
+
+### Manual StateFlow → callback
+
+```kotlin
+// iosMain
+class HomeViewModelIos(private val viewModel: HomeViewModel) {
+
+    private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+
+    fun observeState(onChange: (HomeUiState) -> Unit): () -> Unit {
+        val job = scope.launch {
+            viewModel.uiState.collect { onChange(it) }
+        }
+        return { job.cancel() }
+    }
+
+    fun cancel() {
+        scope.cancel()
+    }
+}
+```
+
+```swift
+class HomeViewController: UIViewController {
+    private var cancelObservation: (() -> Void)?
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        cancelObservation = viewModel.observeState { [weak self] state in
+            self?.updateUI(state: state)
+        }
+    }
+
+    deinit {
+        cancelObservation?()
+        viewModel.cancel()
+    }
+}
+```
+
+---
+
+## Thread Safety
+
+Kotlin/Native enforces strict memory isolation rules. Key rules:
+
+1. **Never share mutable state across threads** — Kotlin/Native will throw `InvalidMutabilityException` (pre-1.7) or use the new memory model (1.7+, default)
+2. **Use `@ThreadLocal`** for thread-local mutable state in `iosMain`
+3. **Use `@SharedImmutable`** for constants shared across threads (deprecated in new MM — just use `val`)
+4. **Main dispatcher** — Always use `Dispatchers.Main` for UI updates on iOS
+
+```kotlin
+// iosMain — ensure coroutines run on main thread for UI
+actual fun platformModule(): Module = module {
+    single { Dispatchers.Main }
+}
+```
+
+---
+
+## Sealed Classes in Swift
+
+Kotlin sealed classes generate a class hierarchy in Swift, but Swift `switch` won't enforce exhaustiveness. Use SKIE or document the pattern:
+
+```kotlin
+// commonMain
+sealed class AuthState {
+    data object Unauthenticated : AuthState()
+    data class Authenticated(val userId: String) : AuthState()
+    data object Loading : AuthState()
+}
+```
+
+```swift
+// Without SKIE — verbose and not exhaustive
+if let state = authState as? AuthStateAuthenticated {
+    print(state.userId)
+} else if authState is AuthStateUnauthenticated {
+    showLogin()
+}
+
+// With SKIE — sealed classes become Swift enums (exhaustive)
+switch authState {
+case .unauthenticated: showLogin()
+case .authenticated(let userId): showHome(userId: userId)
+case .loading: showSpinner()
+}
+```
+
+---
+
+## Xcode Build Integration (Local Development)
+
+To use the local KMP build in Xcode without SPM remote package:
+
+1. Add a **Run Script build phase** to the iOS target:
+
+```bash
+cd "$SRCROOT/../.."
+./gradlew :shared:assembleDebugXCFramework
+```
+
+2. Add the XCFramework output as a local framework:
+   - `Build Phases → Link Binary With Libraries → Add Other → Add Files`
+   - Select `shared/build/XCFrameworks/debug/shared.xcframework`
+
+3. Set **Embed & Sign** for the framework in `Frameworks, Libraries, and Embedded Content`
+
+**Or** use the local SPM package approach (simpler):
+
+```swift
+// Package.swift for local development (no binaryTarget)
+.target(
+    name: "MySharedWrapper",
+    dependencies: ["MySharedBinary"]
+    // points to local path, no URL/checksum needed
+)
+```
+
+---
+
+## Debugging Kotlin Code from Xcode
+
+1. **LLDB in Xcode**: Breakpoints can be set in `.kt` files when using `embedAndSignAppleFrameworkForXcode` Gradle task
+2. **Enable dSYM**: In `build.gradle.kts`:
+   ```kotlin
+   iosArm64 {
+       binaries.framework {
+           debuggable = true
+           isStatic = true
+       }
+   }
+   ```
+3. **Kotlin/Native memory leak debugging**: Use the `kotlin.native.internal.GC.collect()` and monitor with Instruments
+
+---
+
+## Logging from iOS
+
+Use `os_log` (not `NSLog` — it is deprecated for structured logging) via `expect/actual`. `os_log` integrates with the unified logging system visible in Console.app and Instruments.
+
+```kotlin
+// iosMain
+import platform.darwin.*
+
+actual fun logDebug(tag: String, message: String) {
+    os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_DEBUG, "[$tag] $message")
+}
+
+actual fun logInfo(tag: String, message: String) {
+    os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_INFO, "[$tag] $message")
+}
+
+actual fun logWarn(tag: String, message: String) {
+    os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_ERROR, "[$tag] WARN: $message")
+}
+
+actual fun logError(tag: String, message: String, throwable: Throwable?) {
+    os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_FAULT, "[$tag] ERROR: $message ${throwable?.message ?: ""}")
+}
+```
+
+**Log level mapping:**
+
+| Kotlin Level | `os_log` Type | Visible in |
+|---|---|---|
+| DEBUG | `OS_LOG_TYPE_DEBUG` | Instruments / Console (debug builds) |
+| INFO | `OS_LOG_TYPE_INFO` | Console.app |
+| WARN | `OS_LOG_TYPE_ERROR` | Console.app + crash reports |
+| ERROR | `OS_LOG_TYPE_FAULT` | Console.app + crash reports + telemetry |
+
+**Never log sensitive data** (auth tokens, passwords, PII) — `os_log` entries can persist on device.
+
+```kotlin
+// androidMain
+actual fun logDebug(tag: String, message: String) {
+    Log.d(tag, message)
+}
+
+actual fun logError(tag: String, message: String, throwable: Throwable?) {
+    Log.e(tag, message, throwable)
+}
+```
+
+---
+
+## SKIE — Sealed Class Edge Cases
+
+SKIE converts Kotlin sealed classes to Swift enums, but there are important edge cases:
+
+### Generic Sealed Classes
+
+SKIE cannot convert sealed classes with generic type parameters to exhaustive Swift enums. Avoid generics in sealed classes exposed to iOS:
+
+```kotlin
+// AVOID — SKIE cannot generate exhaustive Swift enum for this
+sealed class Result<T> {
+    data class Success<T>(val data: T) : Result<T>()
+    data class Error<T>(val error: String) : Result<T>()
+}
+
+// PREFER — use concrete types at the iOS API boundary
+sealed class UserResult {
+    data class Success(val user: User) : UserResult()
+    data class Error(val message: String) : UserResult()
+}
+```
+
+### Nested Sealed Classes
+
+SKIE flattens nested sealed hierarchies. Deeply nested classes like `AppError.Network.NoConnection` become `AppErrorNetworkNoConnection` in Swift — document this mapping:
+
+```kotlin
+// Kotlin
+sealed class AuthState {
+    data object Loading : AuthState()
+    data class Authenticated(val token: String) : AuthState()
+    sealed class Error : AuthState() {
+        data object InvalidCredentials : Error()
+        data object NetworkError : Error()
+    }
+}
+```
+
+```swift
+// With SKIE — switch cases
+switch state {
+case .loading: showSpinner()
+case .authenticated(let token): storeToken(token)
+case .errorInvalidCredentials: showLoginError()
+case .errorNetworkError: showNetworkError()
+// Note: Error subclass prefix is added to distinguish nested cases
+}
+```
+
+### Skipping SKIE for Specific Types
+
+Opt out of SKIE transformation for specific types using `@SealedInterop.Disabled`:
+
+```kotlin
+@SealedInterop.Disabled
+sealed class InternalEvent {  // not exposed to Swift — kept as class hierarchy
+    class ItemAdded(val id: String) : InternalEvent()
+}
+```
+
+---
+
+## Kotlin/Native Memory Model
+
+Kotlin/Native uses the **new memory model** (default since Kotlin 1.7.20) which removes the strict object freeze requirement. Key implications:
+
+1. **Mutable state CAN be shared across threads** — but you must still synchronize access explicitly
+2. **No more `InvalidMutabilityException`** — objects are no longer frozen automatically
+3. **Use `AtomicReference` for thread-safe state** in `iosMain`:
+
+```kotlin
+// iosMain — thread-safe shared mutable reference
+import kotlin.native.concurrent.AtomicReference
+
+class SharedCounter {
+    private val _count = AtomicReference(0)
+
+    fun increment() {
+        _count.value++
+    }
+
+    fun get(): Int = _count.value
+}
+```
+
+4. **Coroutines on iOS** — always use `Dispatchers.Main` for UI updates; background work uses `Dispatchers.Default` (maps to a background thread pool):
+
+```kotlin
+// iosMain — correct dispatcher usage
+actual fun platformModule(): Module = module {
+    single<CoroutineDispatcher> { Dispatchers.Main }
+}
+```
+
+5. **GC differences** — Kotlin/Native uses a different GC than JVM. Avoid creating large object graphs that reference each other across thread boundaries; prefer passing data by value (data classes) over references.
+
+6. **Objective-C object lifecycle** — When bridging to Swift, Kotlin objects retain their reference count. Avoid circular references between Kotlin and Swift objects — use `weak` on the Swift side:
+
+```swift
+class HomeViewController: UIViewController {
+    // Weak reference prevents retain cycle with Kotlin ViewModel
+    private weak var viewModel: HomeViewModelIos?
+}
+```
+
+---
+
+## iOS Performance Considerations
+
+1. **`isStatic = true` for frameworks** — always use static frameworks for iOS to avoid dynamic linking overhead:
+
+```kotlin
+iosArm64 {
+    binaries.framework {
+        baseName = "shared"
+        isStatic = true  // required for App Store; avoids dyld loading cost
+    }
+}
+```
+
+2. **Minimize Kotlin↔Swift boundary crossings in hot paths** — each call across the boundary incurs overhead (ObjC message dispatch). Batch data instead of iterating Kotlin collections from Swift:
+
+```kotlin
+// GOOD — one call, returns all data
+fun getItems(): List<Item> = repository.getAllCached()
+
+// BAD in a loop — each call crosses the Kotlin/ObjC boundary
+// Swift: for i in 0..<viewModel.itemCount { let item = viewModel.getItem(i) }
+```
+
+3. **Avoid suspending functions that return `Unit` to Swift** — prefer callback-based APIs at iOS boundaries (or use SKIE):
+
+```kotlin
+// iosMain — iOS-friendly callback API for Swift that doesn't use SKIE
+fun loadData(onResult: (List<Item>) -> Unit, onError: (String) -> Unit) {
+    CoroutineScope(Dispatchers.Main).launch {
+        when (val result = repository.getItems()) {
+            is Resource.Success -> onResult(result.data)
+            is Resource.Error -> onError(result.error.toUserMessage())
+            is Resource.Loading -> {}
+        }
+    }
+}
+```
+
+4. **XCFramework size** — enable Bitcode and LLVM optimizations in release builds:
+
+```kotlin
+iosArm64 {
+    binaries.framework {
+        freeCompilerArgs += listOf("-Xoptimization-passes=2")
+        optimized = true  // enables dead code elimination
+    }
+}
+```
+
+---
+
+## Public iOS API Design Guidelines
+
+When exposing Kotlin APIs to Swift/iOS:
+
+1. **Keep the public API small** — only expose what iOS needs, internals stay `internal`
+2. **Use `@HiddenFromObjC`** to exclude Kotlin-internal types from the Swift API
+3. **Avoid generics in public API** — Kotlin generics don't bridge cleanly to Swift
+4. **Prefer data classes over complex hierarchies** — easier to bridge
+5. **Name iOS-facing functions clearly** — `getUser(id:)` not `fetchUserById(id:)`
+
+```kotlin
+// Mark internal Kotlin utilities as hidden from Swift
+@HiddenFromObjC
+internal fun internalHelper(): String = "not exposed to Swift"
+```

+ 504 - 0
.claude/skills/kmp-compose-multiplatform/references/navigation.md

@@ -0,0 +1,504 @@
+# Navigation — KMP + Compose Multiplatform
+
+References: [Navigation Compose](https://developer.android.com/guide/navigation/design/kotlin-dsl) | [Deep Links](https://developer.android.com/guide/navigation/design/deep-link) | [Predictive Back](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture)
+
+---
+
+## Type-Safe Routes
+
+Define all routes as a sealed class. Argument types are declared explicitly — never use raw strings in navigation calls:
+
+```kotlin
+// commonMain/presentation/ui/navigation/Screen.kt
+sealed class Screen(val route: String) {
+    data object Home : Screen("home")
+    data object Settings : Screen("settings")
+
+    data object Detail : Screen("detail/{id}") {
+        const val ARG_ID = "id"
+        fun createRoute(id: String) = "detail/$id"
+    }
+
+    data object Profile : Screen("profile/{userId}?tab={tab}") {
+        const val ARG_USER_ID = "userId"
+        const val ARG_TAB = "tab"
+        fun createRoute(userId: String, tab: String = "posts") = "profile/$userId?tab=$tab"
+    }
+}
+```
+
+---
+
+## NavHost Setup with Transitions
+
+```kotlin
+@Composable
+fun AppNavigation(
+    navController: NavHostController = rememberNavController(),
+    startDestination: String = Screen.Home.route
+) {
+    NavHost(
+        navController = navController,
+        startDestination = startDestination,
+        enterTransition = {
+            slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, tween(300))
+        },
+        exitTransition = {
+            slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, tween(300))
+        },
+        popEnterTransition = {
+            slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, tween(300))
+        },
+        popExitTransition = {
+            slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, tween(300))
+        }
+    ) {
+        composable(Screen.Home.route) {
+            HomeScreen(navController = navController)
+        }
+
+        composable(
+            route = Screen.Detail.route,
+            arguments = listOf(
+                navArgument(Screen.Detail.ARG_ID) { type = NavType.StringType }
+            ),
+            deepLinks = listOf(
+                navDeepLink { uriPattern = "myapp://detail/{id}" },
+                navDeepLink { uriPattern = "https://myapp.com/detail/{id}" }
+            )
+        ) { backStackEntry ->
+            val id = backStackEntry.arguments?.getString(Screen.Detail.ARG_ID)
+                ?: return@composable
+            DetailScreen(id = id, onBack = navController::popBackStack)
+        }
+
+        composable(
+            route = Screen.Profile.route,
+            arguments = listOf(
+                navArgument(Screen.Profile.ARG_USER_ID) { type = NavType.StringType },
+                navArgument(Screen.Profile.ARG_TAB) {
+                    type = NavType.StringType
+                    defaultValue = "posts"
+                }
+            )
+        ) { backStackEntry ->
+            val userId = backStackEntry.arguments?.getString(Screen.Profile.ARG_USER_ID) ?: return@composable
+            val tab = backStackEntry.arguments?.getString(Screen.Profile.ARG_TAB) ?: "posts"
+            ProfileScreen(userId = userId, initialTab = tab)
+        }
+    }
+}
+```
+
+---
+
+## Deep Links
+
+### Android Setup
+
+Declare deep link intent filters in `AndroidManifest.xml`:
+
+```xml
+<activity android:name=".MainActivity">
+    <intent-filter android:autoVerify="true">
+        <action android:name="android.intent.action.VIEW" />
+        <category android:name="android.intent.category.DEFAULT" />
+        <category android:name="android.intent.category.BROWSABLE" />
+        <data android:scheme="https" android:host="myapp.com" />
+        <data android:scheme="myapp" />
+    </intent-filter>
+</activity>
+```
+
+Pass the intent to `NavController` in `MainActivity`:
+
+```kotlin
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContent {
+            val navController = rememberNavController()
+            AppTheme {
+                AppNavigation(navController = navController)
+            }
+            // Handle deep link from intent
+            LaunchedEffect(intent) {
+                navController.handleDeepLink(intent)
+            }
+        }
+    }
+}
+```
+
+### iOS Deep Link Handling
+
+Handle universal links and custom URL schemes in Swift:
+
+```swift
+// iOSApp.swift
+@main
+struct iOSApp: App {
+    var body: some Scene {
+        WindowGroup {
+            ContentView()
+                .onOpenURL { url in
+                    DeepLinkHandlerKt.handleDeepLink(url: url.absoluteString)
+                }
+        }
+    }
+}
+```
+
+```kotlin
+// iosMain — expose deep link handler
+object DeepLinkHandler {
+    var onDeepLink: ((String) -> Unit)? = null
+
+    fun handleDeepLink(url: String) {
+        onDeepLink?.invoke(url)
+    }
+}
+```
+
+---
+
+## Nested Navigation (Bottom Navigation)
+
+Use a nested `NavHost` per tab — each tab maintains its own back stack:
+
+```kotlin
+@Composable
+fun MainScreen() {
+    val navController = rememberNavController()
+    val tabs = listOf(Tab.Home, Tab.Search, Tab.Profile)
+
+    Scaffold(
+        bottomBar = {
+            NavigationBar {
+                val currentDestination by navController.currentBackStackEntryAsState()
+                tabs.forEach { tab ->
+                    NavigationBarItem(
+                        selected = currentDestination?.destination?.hierarchy
+                            ?.any { it.route == tab.route } == true,
+                        onClick = {
+                            navController.navigate(tab.route) {
+                                // Avoid building up a large back stack
+                                popUpTo(navController.graph.findStartDestination().id) {
+                                    saveState = true
+                                }
+                                launchSingleTop = true
+                                restoreState = true  // Restore tab state on reselect
+                            }
+                        },
+                        icon = { Icon(tab.icon, contentDescription = tab.label) },
+                        label = { Text(tab.label) }
+                    )
+                }
+            }
+        }
+    ) { innerPadding ->
+        NavHost(
+            navController = navController,
+            startDestination = Tab.Home.route,
+            modifier = Modifier.padding(innerPadding)
+        ) {
+            homeGraph(navController)
+            searchGraph(navController)
+            profileGraph(navController)
+        }
+    }
+}
+
+// Nested graph per tab
+fun NavGraphBuilder.homeGraph(navController: NavHostController) {
+    navigation(startDestination = Screen.Home.route, route = Tab.Home.route) {
+        composable(Screen.Home.route) { HomeScreen(navController) }
+        composable(Screen.Detail.route) { /* ... */ }
+    }
+}
+```
+
+---
+
+## Back Navigation
+
+### Predictive Back Gesture (Android 14+)
+
+Use `BackHandler` to intercept system back:
+
+```kotlin
+@Composable
+fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) {
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+    // Intercept back only when there's unsaved state
+    BackHandler(enabled = uiState.hasUnsavedChanges) {
+        viewModel.onBackPressed()  // Show confirmation dialog
+    }
+
+    // ...
+}
+```
+
+### Custom Back Navigation
+
+```kotlin
+@Composable
+fun DetailScreen(
+    id: String,
+    onBack: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    Scaffold(
+        topBar = {
+            TopAppBar(
+                title = { Text("Detail") },
+                navigationIcon = {
+                    IconButton(onClick = onBack) {
+                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+                    }
+                }
+            )
+        }
+    ) { ... }
+}
+```
+
+---
+
+## Navigation State in ViewModel (via SharedFlow)
+
+Never navigate directly from a ViewModel. Emit events via `SharedFlow` and handle them in the composable:
+
+```kotlin
+// ViewModel
+class HomeViewModel : ViewModel() {
+    private val _events = MutableSharedFlow<HomeEvent>()
+    val events: SharedFlow<HomeEvent> = _events.asSharedFlow()
+
+    fun onItemClicked(id: String) {
+        viewModelScope.launch {
+            _events.emit(HomeEvent.NavigateToDetail(id))
+        }
+    }
+}
+
+sealed class HomeEvent {
+    data class NavigateToDetail(val id: String) : HomeEvent()
+    data object NavigateToSettings : HomeEvent()
+}
+
+// Screen composable
+@Composable
+fun HomeScreen(
+    navController: NavHostController,
+    viewModel: HomeViewModel = koinViewModel()
+) {
+    LaunchedEffect(Unit) {
+        viewModel.events.collect { event ->
+            when (event) {
+                is HomeEvent.NavigateToDetail ->
+                    navController.navigate(Screen.Detail.createRoute(event.id))
+                HomeEvent.NavigateToSettings ->
+                    navController.navigate(Screen.Settings.route)
+            }
+        }
+    }
+}
+```
+
+---
+
+## Navigation State Persistence (Process Death)
+
+Use `rememberSaveable` for UI state that should survive process death. For navigation stack, the `NavController` saves and restores automatically via `SavedStateHandle`:
+
+```kotlin
+// Access navigation arguments in ViewModel via SavedStateHandle
+class DetailViewModel(
+    savedStateHandle: SavedStateHandle,
+    private val getItemUseCase: GetItemUseCase
+) : ViewModel() {
+
+    private val itemId: String = checkNotNull(savedStateHandle[Screen.Detail.ARG_ID])
+
+    val uiState: StateFlow<DetailUiState> = getItemUseCase(itemId)
+        .map { resource -> resource.toUiState() }
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DetailUiState())
+}
+```
+
+Register in Koin with `SavedStateHandle`:
+
+```kotlin
+val detailModule = module {
+    viewModel { params -> DetailViewModel(savedStateHandle = params.get(), getItemUseCase = get()) }
+}
+```
+
+---
+
+## Dialog and Bottom Sheet Navigation
+
+```kotlin
+NavHost(...) {
+    composable(Screen.Home.route) { HomeScreen() }
+
+    // Modal dialog
+    dialog(Screen.ConfirmDelete.route) {
+        ConfirmDeleteDialog(
+            onConfirm = { navController.popBackStack() },
+            onDismiss = { navController.popBackStack() }
+        )
+    }
+
+    // Bottom sheet
+    bottomSheet(Screen.Filter.route) {
+        FilterBottomSheet(
+            onApply = { navController.popBackStack() }
+        )
+    }
+}
+```
+
+---
+
+## Cross-Module Navigation Contracts
+
+In multi-module apps, features must not import each other's `Screen` classes. Use a navigation contract interface in the `:api` module:
+
+```kotlin
+// feature/auth/api/AuthNavigation.kt  (feature:auth:api module)
+interface AuthNavigation {
+    val loginRoute: String
+    val registerRoute: String
+    fun navigateToLogin(navController: NavController)
+    fun navigateAfterLogin(navController: NavController)
+}
+```
+
+```kotlin
+// feature/auth/impl  (implements the contract)
+class AuthNavigationImpl : AuthNavigation {
+    override val loginRoute = "auth/login"
+    override val registerRoute = "auth/register"
+
+    override fun navigateToLogin(navController: NavController) {
+        navController.navigate(loginRoute)
+    }
+
+    override fun navigateAfterLogin(navController: NavController) {
+        navController.navigate("home") {
+            popUpTo(loginRoute) { inclusive = true }
+        }
+    }
+}
+```
+
+Register in the root `NavHost` — only the app-level module knows all feature implementations:
+
+```kotlin
+// app/AppNavigation.kt — wires all feature graphs together
+@Composable
+fun AppNavigation(
+    authNav: AuthNavigation = get(),
+    homeNav: HomeNavigation = get()
+) {
+    val navController = rememberNavController()
+
+    NavHost(navController, startDestination = authNav.loginRoute) {
+        authGraph(navController, authNav)
+        homeGraph(navController, homeNav)
+    }
+}
+```
+
+---
+
+## Predictive Back (Android 14+)
+
+Enable the predictive back gesture animation by adding the flag to the Android manifest and using `PredictiveBackHandler` for custom back animations:
+
+```xml
+<!-- AndroidManifest.xml -->
+<application android:enableOnBackInvokedCallback="true" ...>
+```
+
+```kotlin
+// For a custom screen-level back animation with Predictive Back progress
+@Composable
+fun DetailScreen(onBack: () -> Unit) {
+    var scale by remember { mutableFloatStateOf(1f) }
+
+    PredictiveBackHandler { progress ->
+        // progress is a Flow<BackEventCompat> emitting 0.0 → 1.0 as user swipes
+        try {
+            progress.collect { backEvent ->
+                scale = 1f - (backEvent.progress * 0.1f)  // shrink slightly during gesture
+            }
+            // User committed the back gesture
+            onBack()
+        } catch (e: CancellationException) {
+            // User cancelled the back gesture — restore state
+            scale = 1f
+        }
+    }
+
+    Box(modifier = Modifier.scale(scale)) {
+        DetailContent()
+    }
+}
+```
+
+For the default system animation (no custom handling needed), simply set the manifest flag — the system provides the animation automatically.
+
+---
+
+## Deep Link Validation
+
+Validate deep link parameters before processing — never trust incoming URL data:
+
+```kotlin
+// ViewModel — validate deep link arguments from SavedStateHandle
+class DetailViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
+
+    // Will throw if argument is missing — handle at the NavHost level
+    private val rawId: String = checkNotNull(savedStateHandle[Screen.Detail.ARG_ID]) {
+        "DetailScreen requires a non-null item ID"
+    }
+
+    // Validate format before use
+    val itemId: String = rawId.takeIf { it.isNotBlank() && it.length <= 64 }
+        ?: throw IllegalArgumentException("Invalid item ID: $rawId")
+}
+```
+
+Test deep links in Android with ADB:
+
+```bash
+# Test HTTPS deep link
+adb shell am start -a android.intent.action.VIEW \
+  -d "https://myapp.com/detail/123" \
+  com.example.myapp
+
+# Test custom scheme
+adb shell am start -a android.intent.action.VIEW \
+  -d "myapp://detail/123" \
+  com.example.myapp
+```
+
+Register a `NavDeepLinkRequest` builder in tests to verify routing:
+
+```kotlin
+@Test
+fun deepLink_navigatesToDetailScreen() {
+    val request = NavDeepLinkRequest.Builder
+        .fromUri("https://myapp.com/detail/abc123".toUri())
+        .build()
+    navController.handleDeepLink(request)
+
+    assertEquals(Screen.Detail.route, navController.currentDestination?.route)
+    assertEquals("abc123", navController.currentBackStackEntry
+        ?.arguments?.getString(Screen.Detail.ARG_ID))
+}
+```

+ 695 - 0
.claude/skills/kmp-compose-multiplatform/references/testing.md

@@ -0,0 +1,695 @@
+# Testing — KMP + Compose Multiplatform
+
+References: [Now in Android Testing](https://github.com/android/nowinandroid) | [Compose Testing](https://developer.android.com/develop/ui/compose/testing) | [Coroutines Testing](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/)
+
+---
+
+## Test Dependencies
+
+```toml
+# gradle/libs.versions.toml
+[versions]
+kotlin-test = "2.3.0"
+kotlinx-coroutines-test = "1.10.2"
+turbine = "1.2.0"
+androidx-test-junit = "1.2.1"
+compose-ui-test = "1.8.0"
+
+[libraries]
+kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin-test" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
+androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
+compose-ui-test-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose-ui-test" }
+compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose-ui-test" }
+```
+
+```kotlin
+// shared/build.gradle.kts
+sourceSets {
+    commonTest.dependencies {
+        implementation(libs.kotlin.test)
+        implementation(libs.kotlinx.coroutines.test)
+        implementation(libs.turbine)
+    }
+    androidUnitTest.dependencies {
+        implementation(libs.androidx.test.junit)
+    }
+    androidInstrumentedTest.dependencies {
+        implementation(libs.compose.ui.test.junit)
+        debugImplementation(libs.compose.ui.test.manifest)
+    }
+}
+```
+
+---
+
+## Test Doubles (Fakes over Mocks)
+
+Never use Mockito or MockK. Write fakes — real implementations of interfaces that are controllable in tests.
+
+### Base Fake Pattern
+
+```kotlin
+// commonTest/fake/FakeUserRepository.kt
+class FakeUserRepository : UserRepository {
+
+    // Controllable state
+    private val users = mutableMapOf<String, User>()
+    private val usersFlow = MutableStateFlow<List<User>>(emptyList())
+    var shouldReturnError: AppError? = null
+
+    // Test setup helpers
+    fun addUser(user: User) {
+        users[user.id] = user
+        usersFlow.value = users.values.toList()
+    }
+
+    fun setError(error: AppError) {
+        shouldReturnError = error
+    }
+
+    // Interface implementation
+    override suspend fun getUser(id: String): Resource<User> {
+        shouldReturnError?.let { return Resource.Error(it) }
+        return users[id]?.let { Resource.Success(it) }
+            ?: Resource.Error(AppError.Database.NotFound)
+    }
+
+    override fun observeUsers(): Flow<List<User>> = usersFlow
+}
+```
+
+---
+
+## Use Case Tests
+
+```kotlin
+// commonTest/feature/user/domain/usecase/GetUserUseCaseTest.kt
+class GetUserUseCaseTest {
+
+    private val repository = FakeUserRepository()
+    private val useCase = GetUserUseCase(repository)
+
+    @Test
+    fun `returns success when user exists`() = runTest {
+        val expected = User(id = "1", name = "Alice")
+        repository.addUser(expected)
+
+        val result = useCase("1")
+
+        assertIs<Resource.Success<User>>(result)
+        assertEquals(expected, result.data)
+    }
+
+    @Test
+    fun `returns not found error when user missing`() = runTest {
+        val result = useCase("unknown-id")
+
+        assertIs<Resource.Error>(result)
+        assertIs<AppError.Database.NotFound>(result.error)
+    }
+
+    @Test
+    fun `propagates repository error`() = runTest {
+        repository.setError(AppError.Network.NoConnection)
+
+        val result = useCase("1")
+
+        assertIs<Resource.Error>(result)
+        assertIs<AppError.Network.NoConnection>(result.error)
+    }
+}
+```
+
+---
+
+## ViewModel Tests
+
+Use [Turbine](https://github.com/cashapp/turbine) for Flow testing:
+
+```kotlin
+// commonTest/feature/home/presentation/viewmodel/HomeViewModelTest.kt
+class HomeViewModelTest {
+
+    private val repository = FakeUserRepository()
+    private val getItemsUseCase = GetItemsUseCase(repository)
+    private lateinit var viewModel: HomeViewModel
+
+    @BeforeTest
+    fun setup() {
+        Dispatchers.setMain(UnconfinedTestDispatcher())
+        viewModel = HomeViewModel(getItemsUseCase)
+    }
+
+    @AfterTest
+    fun tearDown() {
+        Dispatchers.resetMain()
+    }
+
+    @Test
+    fun `initial state is empty and not loading`() = runTest {
+        val state = viewModel.uiState.value
+        assertFalse(state.isLoading)
+        assertTrue(state.items.isEmpty())
+        assertNull(state.errorMessage)
+    }
+
+    @Test
+    fun `loading then success flow`() = runTest {
+        repository.addUser(User(id = "1", name = "Alice"))
+
+        viewModel.uiState.test {
+            // Initial idle state
+            val idle = awaitItem()
+            assertFalse(idle.isLoading)
+
+            viewModel.loadItems()
+
+            // Loading state
+            val loading = awaitItem()
+            assertTrue(loading.isLoading)
+
+            // Success state
+            val success = awaitItem()
+            assertFalse(success.isLoading)
+            assertEquals(1, success.items.size)
+            assertNull(success.errorMessage)
+        }
+    }
+
+    @Test
+    fun `error state set on failure`() = runTest {
+        repository.setError(AppError.Network.NoConnection)
+
+        viewModel.uiState.test {
+            awaitItem() // initial
+
+            viewModel.loadItems()
+            awaitItem() // loading
+
+            val error = awaitItem()
+            assertFalse(error.isLoading)
+            assertNotNull(error.errorMessage)
+            assertTrue(error.items.isEmpty())
+        }
+    }
+}
+```
+
+---
+
+## Repository Integration Tests (commonTest)
+
+Test the repository with a fake data source — not the real network, but a real local implementation where possible:
+
+```kotlin
+// commonTest/feature/user/data/repository/UserRepositoryImplTest.kt
+class UserRepositoryImplTest {
+
+    private val fakeRemote = FakeUserRemoteDataSource()
+    private val fakeLocal = FakeUserLocalDataSource()
+    private val repository = UserRepositoryImpl(fakeRemote, fakeLocal)
+
+    @Test
+    fun `fetches from remote and caches locally on success`() = runTest {
+        val remoteUser = UserDto(id = "1", name = "Alice")
+        fakeRemote.setUser(remoteUser)
+
+        val result = repository.getUser("1")
+
+        assertIs<Resource.Success<User>>(result)
+        assertEquals("Alice", result.data.name)
+        // Verify local cache was populated
+        assertNotNull(fakeLocal.getUser("1"))
+    }
+
+    @Test
+    fun `returns cached data on network failure`() = runTest {
+        fakeLocal.addUser(UserEntity(id = "1", name = "Alice (cached)"))
+        fakeRemote.setError(AppError.Network.NoConnection)
+
+        val result = repository.getUser("1")
+
+        assertIs<Resource.Success<User>>(result)
+        assertEquals("Alice (cached)", result.data.name)
+    }
+}
+```
+
+---
+
+## Room In-Memory Database Tests (androidUnitTest)
+
+```kotlin
+// androidUnitTest/data/local/UserDaoTest.kt
+@RunWith(AndroidJUnit4::class)
+class UserDaoTest {
+
+    private lateinit var database: AppDatabase
+    private lateinit var userDao: UserDao
+
+    @Before
+    fun setup() {
+        database = Room.inMemoryDatabaseBuilder(
+            context = ApplicationProvider.getApplicationContext(),
+            klass = AppDatabase::class.java
+        ).allowMainThreadQueries().build()
+        userDao = database.userDao()
+    }
+
+    @After
+    fun tearDown() {
+        database.close()
+    }
+
+    @Test
+    fun insertAndRetrieveUser() = runTest {
+        val entity = UserEntity(id = "1", name = "Alice", email = "alice@example.com")
+        userDao.insert(entity)
+
+        val result = userDao.getById("1")
+        assertEquals(entity, result)
+    }
+
+    @Test
+    fun observeUsersEmitsOnInsert() = runTest {
+        userDao.observeAll().test {
+            assertEquals(emptyList<UserEntity>(), awaitItem())
+
+            userDao.insert(UserEntity(id = "1", name = "Alice", email = ""))
+            val updated = awaitItem()
+            assertEquals(1, updated.size)
+        }
+    }
+}
+```
+
+---
+
+## Compose UI Tests (androidInstrumentedTest)
+
+```kotlin
+// androidInstrumentedTest/feature/home/HomeScreenTest.kt
+@RunWith(AndroidJUnit4::class)
+class HomeScreenTest {
+
+    @get:Rule
+    val composeTestRule = createComposeRule()
+
+    @Test
+    fun showsLoadingIndicator_whenStateIsLoading() {
+        composeTestRule.setContent {
+            AppTheme {
+                HomeContent(
+                    uiState = HomeUiState(isLoading = true),
+                    onRetry = {}
+                )
+            }
+        }
+
+        composeTestRule
+            .onNodeWithContentDescription("Loading")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun showsItems_whenStateIsSuccess() {
+        val items = listOf(Item(id = "1", title = "First item"))
+
+        composeTestRule.setContent {
+            AppTheme {
+                HomeContent(
+                    uiState = HomeUiState(items = items),
+                    onRetry = {}
+                )
+            }
+        }
+
+        composeTestRule
+            .onNodeWithText("First item")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun showsErrorAndRetryButton_whenStateHasError() {
+        var retryClicked = false
+
+        composeTestRule.setContent {
+            AppTheme {
+                HomeContent(
+                    uiState = HomeUiState(errorMessage = "No internet connection"),
+                    onRetry = { retryClicked = true }
+                )
+            }
+        }
+
+        composeTestRule
+            .onNodeWithText("No internet connection")
+            .assertIsDisplayed()
+
+        composeTestRule
+            .onNodeWithText("Retry")
+            .performClick()
+
+        assertTrue(retryClicked)
+    }
+}
+```
+
+---
+
+## Accessibility Testing
+
+Test that composables expose correct semantics for screen readers:
+
+```kotlin
+@RunWith(AndroidJUnit4::class)
+class HomeScreenAccessibilityTest {
+
+    @get:Rule
+    val composeTestRule = createComposeRule()
+
+    @Test
+    fun favoriteButton_hasCorrectContentDescription() {
+        composeTestRule.setContent {
+            AppTheme {
+                ItemCard(
+                    item = Item(id = "1", title = "My Item", isFavorite = false),
+                    onFavoriteClick = {}
+                )
+            }
+        }
+
+        composeTestRule
+            .onNodeWithContentDescription("Add to favorites")
+            .assertIsDisplayed()
+            .assertHasClickAction()
+    }
+
+    @Test
+    fun loadingIndicator_hasAccessibleDescription() {
+        composeTestRule.setContent {
+            AppTheme {
+                HomeContent(uiState = HomeUiState(isLoading = true), onRetry = {})
+            }
+        }
+
+        composeTestRule
+            .onNodeWithContentDescription("Loading")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun errorState_retryButtonIsAccessible() {
+        composeTestRule.setContent {
+            AppTheme {
+                HomeContent(
+                    uiState = HomeUiState(errorMessage = "No connection"),
+                    onRetry = {}
+                )
+            }
+        }
+
+        composeTestRule
+            .onNodeWithText("Retry")
+            .assertHasClickAction()
+            .assertIsEnabled()
+    }
+
+    @Test
+    fun mergedSemantics_cardExposesCorrectDescription() {
+        val item = Item(id = "1", title = "Alice", subtitle = "Engineer")
+
+        composeTestRule.setContent {
+            AppTheme { UserCard(user = item) }
+        }
+
+        // Merged semantics should produce one accessible node
+        composeTestRule
+            .onNodeWithContentDescription("Alice, Engineer", substring = true)
+            .assertExists()
+    }
+}
+```
+
+---
+
+## SharedFlow Event Testing
+
+Test one-time navigation/UI events emitted via `SharedFlow`:
+
+```kotlin
+// commonTest/feature/home/presentation/viewmodel/HomeViewModelEventTest.kt
+class HomeViewModelEventTest {
+
+    private val repository = FakeUserRepository()
+    private val viewModel = HomeViewModel(GetItemsUseCase(repository))
+
+    @BeforeTest
+    fun setup() {
+        Dispatchers.setMain(UnconfinedTestDispatcher())
+    }
+
+    @AfterTest
+    fun tearDown() {
+        Dispatchers.resetMain()
+    }
+
+    @Test
+    fun `onItemClicked emits NavigateToDetail event`() = runTest {
+        viewModel.events.test {
+            viewModel.onItemClicked("item-123")
+
+            val event = awaitItem()
+            assertIs<HomeEvent.NavigateToDetail>(event)
+            assertEquals("item-123", event.id)
+
+            cancelAndConsumeRemainingEvents()
+        }
+    }
+
+    @Test
+    fun `delete action emits ShowUndoSnackbar event`() = runTest {
+        viewModel.events.test {
+            viewModel.onDeleteItem("item-456")
+
+            val event = awaitItem()
+            assertIs<HomeEvent.ShowUndoSnackbar>(event)
+        }
+    }
+
+    @Test
+    fun `multiple events are emitted in order`() = runTest {
+        viewModel.events.test {
+            viewModel.onItemClicked("first")
+            viewModel.onItemClicked("second")
+
+            assertEquals("first", (awaitItem() as HomeEvent.NavigateToDetail).id)
+            assertEquals("second", (awaitItem() as HomeEvent.NavigateToDetail).id)
+        }
+    }
+}
+```
+
+---
+
+## Paging 3 Tests
+
+Test `PagingData` streams using `paging-testing` artifact:
+
+```kotlin
+// androidUnitTest/feature/home/data/repository/ItemRepositoryPagingTest.kt
+@RunWith(AndroidJUnit4::class)
+class ItemRepositoryPagingTest {
+
+    private val fakeDao = FakeItemDao()
+    private val repository = ItemRepositoryImpl(fakeDao)
+
+    @Test
+    fun pagingSource_loadsFirstPage() = runTest {
+        // Seed fake DAO with 50 items
+        repeat(50) { i -> fakeDao.insert(ItemEntity(id = "item-$i", title = "Item $i")) }
+
+        val pager = Pager(
+            config = PagingConfig(pageSize = 20, enablePlaceholders = false),
+            pagingSourceFactory = { fakeDao.pagingSource() }
+        )
+
+        val snapshot = pager.flow
+            .asSnapshot()  // from paging-testing — collects and waits for first page
+
+        assertEquals(20, snapshot.size)
+        assertEquals("item-0", snapshot.first().id)
+    }
+
+    @Test
+    fun pagingSource_loadsAllItems_withScrolling() = runTest {
+        repeat(45) { i -> fakeDao.insert(ItemEntity(id = "item-$i", title = "Item $i")) }
+
+        val snapshot = Pager(
+            config = PagingConfig(pageSize = 20),
+            pagingSourceFactory = { fakeDao.pagingSource() }
+        ).flow.asSnapshot {
+            scrollTo(index = 44)  // scroll to trigger all pages to load
+        }
+
+        assertEquals(45, snapshot.size)
+    }
+}
+```
+
+---
+
+## Screenshot / Golden Tests
+
+Use Paparazzi (Android) or Roborazzi for composable screenshot regression tests:
+
+```toml
+# libs.versions.toml
+paparazzi = "1.3.5"
+[plugins]
+paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }
+```
+
+```kotlin
+// androidUnitTest/feature/home/HomeScreenScreenshotTest.kt
+@RunWith(JUnit4::class)
+class HomeScreenScreenshotTest {
+
+    @get:Rule
+    val paparazzi = Paparazzi(
+        deviceConfig = DeviceConfig.PIXEL_5,
+        theme = "android:Theme.Material.Light.NoActionBar"
+    )
+
+    @Test
+    fun homeScreen_loadingState() {
+        paparazzi.snapshot {
+            AppTheme {
+                HomeContent(
+                    uiState = HomeUiState(isLoading = true),
+                    onAction = {}
+                )
+            }
+        }
+    }
+
+    @Test
+    fun homeScreen_successState() {
+        paparazzi.snapshot {
+            AppTheme {
+                HomeContent(
+                    uiState = HomeUiState(items = PreviewData.items),
+                    onAction = {}
+                )
+            }
+        }
+    }
+
+    @Test
+    fun homeScreen_errorState() {
+        paparazzi.snapshot {
+            AppTheme {
+                HomeContent(
+                    uiState = HomeUiState(errorMessage = "No internet connection"),
+                    onRetry = {}
+                )
+            }
+        }
+    }
+
+    @Test
+    fun homeScreen_darkTheme() {
+        paparazzi.snapshot {
+            AppTheme(darkTheme = true) {
+                HomeContent(
+                    uiState = HomeUiState(items = PreviewData.items),
+                    onAction = {}
+                )
+            }
+        }
+    }
+}
+```
+
+Run golden comparison:
+```bash
+# Record golden images (first run or after intentional UI changes)
+./gradlew :shared:recordPaparazziDebug
+
+# Verify screenshots match goldens (CI)
+./gradlew :shared:verifyPaparazziDebug
+```
+
+---
+
+## Koin Test Modules
+
+```kotlin
+// commonTest/di/TestModules.kt
+val testNetworkModule = module {
+    single<UserRepository> { FakeUserRepository() }
+    single<ApiService> { FakeApiService() }
+}
+
+// In ViewModel tests with Koin
+class HomeViewModelKoinTest : KoinTest {
+
+    @get:Rule
+    val koinRule = KoinTestRule.create {
+        modules(testNetworkModule)
+    }
+
+    @Test
+    fun `koin resolves ViewModel correctly`() = runTest {
+        val viewModel: HomeViewModel = get()
+        assertNotNull(viewModel)
+    }
+}
+```
+
+Always call `stopKoin()` after tests that start Koin manually:
+
+```kotlin
+@AfterTest
+fun tearDown() {
+    stopKoin()
+}
+```
+
+---
+
+## Test Structure
+
+```
+shared/
+└── src/
+    ├── commonTest/kotlin/
+    │   ├── fake/
+    │   │   ├── FakeUserRepository.kt
+    │   │   ├── FakeApiService.kt
+    │   │   └── FakeLocalDataSource.kt
+    │   ├── di/
+    │   │   └── TestModules.kt
+    │   └── feature/
+    │       └── home/
+    │           ├── domain/usecase/GetItemsUseCaseTest.kt
+    │           └── presentation/viewmodel/HomeViewModelTest.kt
+    ├── androidUnitTest/kotlin/
+    │   └── data/local/UserDaoTest.kt
+    └── androidInstrumentedTest/kotlin/
+        └── feature/home/HomeScreenTest.kt
+```
+
+---
+
+## Key Rules
+
+- **Never mock** — use fakes with controllable state
+- **Always test error paths** — set errors on fakes and assert correct UI state
+- **Use Turbine** for Flow/StateFlow assertions — cleaner than `toList()` with jobs
+- **`UnconfinedTestDispatcher`** for ViewModel tests (immediate execution)
+- **`StandardTestDispatcher`** for tests where you need explicit advancement with `advanceUntilIdle()`
+- **`stopKoin()`** after any test that calls `startKoin()`
+- **In-memory Room** for DAO tests — never use production database

+ 260 - 0
.claude/skills/kotlin-build-kmp-gradle-governance/SKILL.md

@@ -0,0 +1,260 @@
+---
+name: kotlin-build-kmp-gradle-governance
+description: Use when reviewing or designing Gradle build structure for KMP projects — shared build logic, convention plugins, version catalogs, Android KMP plugin usage, source-set configuration, and module dependency hygiene.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "1.0.0"
+---
+
+# Kotlin Multiplatform Gradle Governance
+
+Use this skill when reviewing or designing Gradle build structure for a Kotlin Multiplatform project.
+
+This skill is intentionally limited to guidance that is grounded in official Android, Gradle, and Kotlin Multiplatform documentation. It should avoid embedding stack-specific assumptions such as a particular DI framework, persistence library, obfuscation configuration, CI provider, or fixed plugin/version matrix unless the user explicitly asks for those.
+
+## Primary goals
+
+The review or design should optimize for:
+
+- shared build logic instead of repeated module configuration
+- clear, stable module boundaries
+- source-set-correct KMP configuration
+- dependency consistency across modules
+- minimal public module surfaces
+- maintainable plugin and dependency management
+
+## Official defaults to prefer
+
+Unless the project has a strong reason not to, prefer:
+
+- convention plugins to share repeated build logic across modules
+- version catalogs for centralized dependency and plugin coordinates
+- explicit source-set-aware KMP configuration
+- narrow module APIs and `implementation` by default instead of exposing transitive dependencies unnecessarily
+- Android KMP library modules configured with the officially supported Android KMP library plugin when that plugin matches the project’s needs
+
+---
+
+## Review dimensions
+
+### 1. Shared build logic
+
+Check whether repeated Gradle configuration is centralized instead of duplicated.
+
+Prefer:
+- convention plugins or equivalent shared build logic
+- one place to define repeated module defaults
+- module build files that stay focused on what is unique to that module
+
+Two common approaches for organizing convention plugins:
+- **`build-logic/` as an included build**: a standalone Gradle build included via `includeBuild("build-logic")` in `settings.gradle`. This approach gives better IDE support, better build caching, and clearer isolation. It is the pattern used in the Android Now in Android reference project and is generally preferred for new projects.
+- **`buildSrc/`**: a special Gradle directory that is automatically included before the main build. It is simpler to set up but offers weaker caching, slightly worse IDE performance on large projects, and is harder to migrate away from.
+
+Either approach can work well. The key is that one of them is used rather than scattering convention logic across module scripts.
+
+Flag as a concern when:
+- many modules copy the same Android, Kotlin, or publishing configuration
+- plugin and compiler settings drift across modules
+- build logic is scattered across unrelated module scripts
+- neither `buildSrc` nor an included build is used in a project large enough to benefit from shared build logic
+
+### 2. Convention plugins
+
+Review whether convention plugins are used appropriately.
+
+**Build-logic location:** Convention plugins can live in `buildSrc/` (treated as an implicit included build) or an explicitly included build directory (commonly `build-logic/`). An explicit included build is generally preferred in modular projects because it supports better caching, cleaner IDE support, and clearer isolation. `buildSrc/` rebuilds on every sync even when unchanged, which can slow multi-module projects. If the project uses `buildSrc/`, consider flagging this for future migration if build performance is a concern.
+
+Check whether:
+- conventions are grouped around real module roles (e.g., `android-library`, `kmp-library`, `app`)
+- the plugin logic configures existing plugins cleanly without unnecessary indirection
+- conventions reduce duplication without hiding important module differences
+- convention plugins enforce standards rather than introducing opaque magic
+- the build-logic location is consistent and clearly understood by contributors
+
+Flag as a concern when:
+- convention plugins become dumping grounds for unrelated logic
+- modules still require large repeated setup despite having convention plugins
+- convention plugins bake in stack-specific choices (DI setup, obfuscation config) that should stay opt-in
+- `buildSrc/` causes noticeable sync slowdowns in a large project that could benefit from an explicit included build
+
+### 3. Version catalogs
+
+Check whether dependency and plugin coordinates are centralized through version catalogs.
+
+Prefer:
+- consistent aliases for shared dependencies
+- central version management
+- reduced string-literal dependency declarations across modules
+
+Flag as a concern when:
+- versions are repeated in many module build files
+- plugin and library coordinates drift across the build
+- catalog usage is inconsistent enough that it no longer provides governance value
+
+### 4. KMP source-set-aware build design
+
+Check whether build configuration respects KMP source sets.
+
+Review whether:
+- shared dependencies are added to shared source sets intentionally
+- platform-specific dependencies stay in platform-specific source sets
+- the build does not treat `commonMain` as a generic dumping ground
+- the build remains correct as more targets are added
+
+Flag as a concern when:
+- platform-only dependencies are configured as if they were common
+- source-set structure is ignored in favor of convenience
+- build logic assumes one platform and applies it globally
+
+### 5. Android target integration in KMP modules
+
+When configuring Android library support inside KMP, prefer official Android/Kotlin-supported integration patterns.
+
+The Android KMP library plugin (`com.android.kotlin.multiplatform.library`) is the officially supported path for adding Android target support to KMP library modules. Verify that it is available for and compatible with the AGP version the project uses.
+
+Check whether:
+- Android support in KMP library modules uses the appropriate official plugin path (`com.android.kotlin.multiplatform.library` or an equivalent current recommendation from Android docs)
+- Android-specific config is isolated cleanly from target-agnostic build logic
+- the build structure reflects the difference between general KMP configuration and Android-specific configuration
+
+Flag as a concern when:
+- Android config is mixed into shared build logic without clear separation
+- unofficial patterns are treated as baseline without a project reason
+- plugin usage obscures which parts of the build are Android-specific
+
+### 6. Modular dependency hygiene
+
+Review module dependencies with Android modularization guidance in mind.
+
+Check whether:
+- each module has a clear purpose
+- public APIs are as small as possible
+- `implementation` is preferred unless consumers truly need exposed types
+- module dependency direction is intentional and understandable
+
+Flag as a concern when:
+- modules expose too much through `api`
+- “shared/core/common” modules collect unrelated responsibilities
+- module boundaries are weak enough that build structure no longer protects architecture
+
+### 7. Plugin management and repository governance
+
+Check whether plugin and dependency resolution are centralized clearly.
+
+Prefer:
+- explicit plugin management
+- explicit repository declaration
+- repository policy that avoids ad hoc per-module repository configuration when that would weaken governance
+
+Flag as a concern when:
+- modules declare repositories inconsistently
+- plugin resolution is scattered
+- build reproducibility depends on hidden local configuration
+
+### 8. Keep build logic generic unless explicitly stack-opinionated
+
+This public skill should not assume:
+- a specific DI framework
+- a specific database library
+- a specific obfuscation setup
+- a specific config-generation tool
+- a specific CI platform
+
+Flag as a concern when:
+- stack-specific setup is treated as universal KMP guidance
+- sample build logic is presented as official baseline when it is only one possible stack choice
+
+### 9. Version and compatibility guidance
+
+Do not hard-code fast-changing version advice into the skill unless the user explicitly asks for a current compatibility recommendation.
+
+Prefer:
+- structural guidance that remains valid as tools evolve
+- directing the user to official release notes and compatibility matrices for current version requirements
+
+When a user explicitly asks for current compatibility information, direct them to:
+- KGP (Kotlin Gradle Plugin) and AGP compatibility table: https://kotlinlang.org/docs/gradle-configure-project.html#apply-the-plugin
+- AGP release notes: https://developer.android.com/build/releases/gradle-plugin
+- Kotlin releases: https://kotlinlang.org/docs/releases.html
+
+Flag as a concern when:
+- the build guidance embeds stale version floors or compatibility claims as timeless rules
+- the project's AGP, KGP, and Kotlin versions are not explicitly pinned in version catalogs
+- the build assumes a compatibility relationship that is not verified against current docs
+
+### 10. Build review output quality
+
+When reviewing build structure, the output should identify:
+- duplicated build logic
+- weak module dependency boundaries
+- source-set mistakes
+- overexposed APIs
+- catalog/convention-plugin opportunities
+- anything stack-specific that should be opt-in rather than baseline
+
+---
+
+## Required output format
+
+When using this skill, respond with:
+
+1. **Build structure summary**
+   - root build organization
+   - module/build-logic layout
+   - plugin/dependency management shape
+
+2. **What is structurally sound**
+   - concrete strengths only
+
+3. **Issues by dimension**
+   - shared build logic
+   - convention plugins
+   - version catalogs
+   - source sets
+   - Android KMP integration
+   - modular dependency hygiene
+   - plugin/repository governance
+   - stack-specific leakage
+   - version/compatibility risk
+
+4. **Severity for each issue**
+   - high / medium / low
+
+5. **Concrete recommendations**
+   - exact restructuring steps
+   - which logic should move to convention plugins
+   - which dependencies should move to catalogs
+   - where source-set placement is wrong
+   - where module APIs should narrow
+
+6. **Suggested target structure**
+   - proposed `build-logic/`, catalog, and module dependency layout if useful
+
+7. **Open risks**
+   - migration cost
+   - likely breakage points
+   - compatibility checks still required
+
+---
+
+## Anti-patterns to flag aggressively
+
+- repeated Gradle config across many modules
+- no convention-based shared build logic in a large modular project
+- platform-specific dependencies placed in common source sets
+- excessive `api` exposure
+- repositories declared ad hoc in many modules
+- stack-specific tools presented as universal KMP baseline
+- stale version rules treated as timeless guidance
+
+---
+
+## References
+
+- Android Modularization Patterns: https://developer.android.com/topic/modularization/patterns
+- Gradle Convention Plugins: https://docs.gradle.org/current/userguide/implementing_gradle_plugins_convention.html
+- Gradle Version Catalogs: https://docs.gradle.org/current/userguide/version_catalogs.html
+- Kotlin Multiplatform project structure and source sets: https://kotlinlang.org/docs/multiplatform/multiplatform-discover-project.html
+- Kotlin Multiplatform DSL reference: https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-dsl-reference.html
+- Android Kotlin Multiplatform plugin: https://developer.android.com/kotlin/multiplatform/plugin

+ 310 - 0
.claude/skills/kotlin-data-kmp-data-layer/SKILL.md

@@ -0,0 +1,310 @@
+---
+name: kotlin-data-kmp-data-layer
+description: Use when implementing or reviewing KMP data layers, including repositories, data sources, source-of-truth design, API exposure, conflict resolution, error handling, and main-safe data operations.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "1.1.0"
+---
+
+# Kotlin Multiplatform Data Layer
+
+Use this skill when designing, implementing, or reviewing the data layer in a Kotlin Multiplatform project.
+
+This skill is intentionally strict. Its purpose is to keep repositories meaningful, data ownership explicit, source-of-truth decisions clear, and upper layers insulated from transport and persistence details.
+
+## Primary goals
+
+The data-layer design should optimize for:
+
+- clear ownership of application data
+- a single source of truth per repository
+- repositories as the entry points to data access
+- clean separation between repositories and data sources
+- conflict resolution across multiple sources in one place
+- API shapes that fit Kotlin best practices
+- immutable exposed data
+- main-safe repository and data-source APIs
+- predictable failure handling
+- testable mapping and coordination logic
+
+---
+
+## Official defaults to prefer
+
+Unless the project has a strong reason not to, prefer these defaults:
+
+- the data layer contains application data and business logic
+- repositories expose data to the rest of the app
+- repositories centralize changes to data
+- repositories resolve conflicts between multiple data sources
+- repositories abstract data sources from the rest of the app
+- repositories act as the entry points to the data layer
+- each data source is responsible for one source only
+- each repository defines a single source of truth
+- one-shot operations use `suspend` functions
+- updates over time use `Flow`
+- exposed data is immutable
+- repository and data-source APIs are main-safe
+
+---
+
+## Review and implementation dimensions
+
+### 1. Data-layer responsibility
+
+The data layer is not only a transport layer.
+
+Check whether:
+- the data layer owns application data concerns
+- business logic that belongs with data ownership is located here
+- upper layers are protected from storage and transport details
+
+Flag as a concern when:
+- the data layer is treated as only an HTTP wrapper
+- business rules around creation, storage, reconciliation, or change tracking leak upward
+- repositories are too thin to add meaningful ownership or coordination
+
+### 2. Repository responsibility
+
+Repositories should be meaningful architectural boundaries.
+
+Check whether each repository is responsible for:
+- exposing data to the rest of the app
+- centralizing changes to that data
+- resolving conflicts between multiple sources
+- abstracting data sources from callers
+- containing business logic related to data ownership and coordination
+
+Flag as a concern when:
+- repositories merely mirror endpoint methods
+- repositories expose transport-layer details directly
+- upper layers coordinate local and remote sources themselves
+- conflict resolution is duplicated outside the repository
+
+### 3. Data-source responsibility
+
+Each data source should work with exactly one source of data.
+
+Examples:
+- a network source
+- a database source
+- a file source
+- an in-memory source
+
+Check whether:
+- each data source has a narrow responsibility
+- repositories depend on data sources
+- other layers do not depend on data sources directly
+
+Flag as a concern when:
+- ViewModels, presenters, use cases, or UI call data sources directly
+- one data source mixes several unrelated storage/transport concerns
+- repositories do not meaningfully sit between callers and sources
+
+### 4. Source of truth
+
+Each repository should define a single source of truth.
+
+Check whether:
+- the source of truth is explicit
+- the data exposed from the repository comes from that source of truth
+- the repository updates that source of truth consistently
+- multi-source reconciliation feeds back into the source of truth
+
+Strong candidates:
+- local database
+- in-memory cache for specific bounded cases
+
+For offline-first behavior, prefer a local data source such as a database as the source of truth.
+
+Flag as a concern when:
+- multiple sources are treated as simultaneously authoritative
+- UI consumes remote responses directly while local state is supposed to be canonical
+- the source-of-truth choice is implicit or unstable
+
+### 5. API shape
+
+The data layer should expose APIs based on the kind of operation.
+
+Prefer:
+- `suspend` functions for one-shot CRUD-style operations
+- `Flow` for observing changes over time
+
+Check whether:
+- one-shot work is modeled as one-shot APIs
+- long-lived observation uses `Flow`
+- callers receive stable APIs that match how the data behaves
+
+Flag as a concern when:
+- streaming data is exposed as repeated polling by upper layers without reason
+- one-shot operations are modeled as long-lived observable streams unnecessarily
+- API style is inconsistent across similar repositories
+
+### 6. Immutability of exposed data
+
+The data exposed by the data layer should be immutable.
+
+Check whether:
+- repositories expose immutable models or collections
+- callers cannot mutate shared state directly
+- data crossing layer boundaries is safe to use concurrently
+
+Flag as a concern when:
+- mutable state is exposed directly from repositories
+- collections are shared in mutable form across layers
+- upper layers can tamper with repository-owned state
+
+### 7. Main-safety and threading
+
+Repository and data-source APIs should be safe to call from the main thread.
+
+That means these classes are responsible for shifting blocking or expensive work to the proper thread when needed.
+
+On **Android/JVM targets**, this typically means dispatching to `Dispatchers.IO` or a similar dispatcher inside repositories and data sources.
+
+On **Kotlin/Native targets** (iOS, macOS, etc.), the threading model changed significantly in Kotlin 1.7.20+ with the new memory model. The old frozen-object restrictions have been removed. Coroutines on Kotlin/Native now behave much more like on JVM. However, some platform SDKs (UIKit, certain iOS APIs) still require main-thread access, so threading discipline remains relevant at the platform boundary.
+
+Check whether:
+- file, database, network, or expensive filtering work is not forced onto callers
+- repositories and data sources encapsulate threading concerns appropriately
+- upper layers are not made responsible for knowing threading details of the data layer
+- platform-specific threading requirements (e.g., main-thread-only iOS APIs) are respected at the edge, not inside shared business logic
+
+Flag as a concern when:
+- callers must manually move work off the main thread for normal repository usage
+- repositories perform blocking work without appropriate dispatching
+- threading policy is inconsistent or undocumented across targets
+- Kotlin/Native-specific threading behavior is assumed to be the same as the old frozen-object model
+
+### 8. Error handling model
+
+The official guidance allows repository and data-source interactions to either succeed or throw exceptions, using Kotlin’s normal error-handling mechanisms such as `try/catch` for suspend functions and `catch` for flows.
+
+Check whether:
+- the project has a consistent error model
+- failures are surfaced deliberately
+- custom exceptions are used when domain-specific failure meaning matters
+- result-wrapper patterns are used only when the project deliberately wants that API shape
+
+Flag as a concern when:
+- errors are swallowed
+- every repository invents a different failure contract
+- strings are used as the primary error model
+- upper layers cannot distinguish meaningful failure cases when they need to
+
+### 9. Naming and ownership clarity
+
+Prefer repository names based on the data they own.
+
+Prefer data-source names based on:
+- the data they handle
+- and the type of source
+
+Check whether:
+- repository names describe owned data, not transport details
+- data-source names describe role and source clearly
+- implementation details do not leak into architecture unnecessarily
+
+Flag as a concern when:
+- naming obscures ownership
+- class names couple callers to a storage technology without reason
+- repository/data-source boundaries are hard to infer from names
+
+### 10. Upper-layer insulation
+
+The data layer should shield the rest of the app from implementation detail.
+
+Check whether:
+- UI/state-holder/domain code depends on repository contracts rather than raw network/database details
+- storage/transport migrations would stay localized inside the data layer
+- data-source technology choices do not leak upward
+
+Flag as a concern when:
+- UI models mirror backend payloads directly
+- persistence schemas leak into presentation
+- changing a storage or network implementation would force changes across many layers
+
+### 11. Testability
+
+The data layer should be testable in isolation.
+
+Check whether:
+- repository coordination logic can be tested
+- mappers are pure and testable
+- source-of-truth behavior can be validated
+- conflict-resolution logic can be exercised with fakes
+- error paths are testable
+
+Flag as a concern when:
+- repository logic depends on hidden globals
+- source-of-truth behavior is implicit and hard to verify
+- coordination logic only works in large integration tests
+
+---
+
+## Required output format
+
+When using this skill, respond with:
+
+1. **Data-layer summary**
+   - repository structure
+   - data-source structure
+   - source-of-truth design
+   - API shape
+
+2. **What is structurally sound**
+   - concrete strengths only
+
+3. **Issues by dimension**
+   - data-layer responsibility
+   - repository responsibility
+   - data-source responsibility
+   - source of truth
+   - API shape
+   - immutability
+   - main-safety
+   - error handling
+   - naming/ownership clarity
+   - upper-layer insulation
+   - testability
+
+4. **Severity for each issue**
+   - high / medium / low
+
+5. **Concrete recommendations**
+   - exact repository/data-source restructuring steps
+   - source-of-truth corrections
+   - API-shape corrections
+   - threading/error-model corrections
+
+6. **Suggested target structure**
+   - proposed repository/data-source layout if useful
+
+7. **Open risks**
+   - migration cost
+   - compatibility concerns
+   - remaining architectural ambiguity
+
+---
+
+## Anti-patterns to flag aggressively
+
+- repositories that only mirror endpoints
+- upper layers depending directly on data sources
+- no explicit single source of truth
+- multiple writable authorities for the same data
+- mutable data exposed from repositories
+- blocking or expensive work forced onto callers
+- transport or persistence details leaking into UI/state holders
+- silent error swallowing
+- inconsistent API shapes for similar operations
+
+---
+
+## References
+
+- Android data layer: https://developer.android.com/topic/architecture/data-layer
+- Android architecture recommendations: https://developer.android.com/topic/architecture/recommendations
+- Kotlin Multiplatform project structure: https://kotlinlang.org/docs/multiplatform/multiplatform-discover-project.html
+- Kotlin coroutines: https://kotlinlang.org/docs/coroutines-overview.html

+ 977 - 0
.claude/skills/kotlin-kmp-code-review/SKILL.md

@@ -0,0 +1,977 @@
+---
+name: kotlin-project-code-review
+description: Use when reviewing implemented Kotlin Multiplatform / Compose Multiplatform code for architecture consistency, business-logic placement, state correctness, concurrency, Compose quality, design-system usage, security, performance, resilience, and maintainability.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "4.2.0"
+---
+
+# Kotlin Multiplatform Code Review
+
+You are reviewing implemented code for a Kotlin Multiplatform app as a senior mobile architect.
+
+Your role is not to praise the implementation. Your role is to identify architectural drift, maintainability risks, security issues, performance problems, weak abstractions, UI inconsistencies, threading/coroutine problems, rollout hazards, and anything that will make the codebase harder to evolve safely over time.
+
+Be highly critical, but practical. Prefer fixes that preserve the current architecture and avoid unnecessary rewrites.
+
+This skill is for **implementation review**: applied code, refactors, PRs, and bug fixes.
+
+It should be usable **on its own** for normal code review. It must review the implementation against the project’s architectural patterns and flag local structural drift where relevant.
+
+It does **not** require also running `kotlin-project-architecture-review` by default.
+
+However, if the implementation appears to materially change:
+- module boundaries
+- source-set placement
+- ownership of source of truth
+- Android entry points
+- navigation architecture
+- shared vs platform-specific boundaries
+- manifest/exported surface
+- feature-level layering strategy
+
+then explicitly say the change also warrants `kotlin-project-architecture-review`.
+
+---
+
+## Review goals
+
+Review the implementation against these priorities:
+1. Architecture consistency
+2. Separation of concerns
+3. Small and focused classes/files
+4. Business logic in the correct layer
+5. Model and boundary integrity
+6. State management correctness
+7. KMP and Compose best practices
+8. Shared UI system usage
+9. Performance and recomposition safety
+10. Coroutine/threading correctness
+11. Exception handling and cancellation correctness
+12. Concurrency and race-condition safety
+13. Dependency injection and lifetime correctness
+14. Persistence/cache/source-of-truth discipline
+15. Security and privacy
+16. Reusability and duplication reduction
+17. Internal API design quality
+18. Testability
+19. Observability and diagnosability
+20. Localization and string handling
+21. Backward compatibility and migration safety
+22. Accessibility and UX robustness
+23. Rollout safety
+24. Long-term maintainability
+
+---
+
+## Core review rules
+
+### 1. Keep business logic out of UI
+
+Business logic should live primarily in domain/use-case/domain model layers, not in composables and not scattered through ViewModels.
+
+Flag and fix cases where:
+- composables decide business rules
+- composables transform raw backend data into business decisions
+- ViewModels contain complex decision trees, pricing logic, validation logic, filtering rules, mapping logic, orchestration that should be delegated, or workflow rules
+- repositories contain UI-oriented logic
+- DTOs leak into UI or domain layers directly
+
+Prefer:
+- domain models
+- use cases / interactors
+- dedicated mappers
+- reducer/state transformation helpers
+- validators in dedicated files/classes
+- repository interfaces returning app-oriented models, not raw transport models where avoidable
+
+### 2. Avoid large ViewModels
+
+A ViewModel should orchestrate state, not become the system.
+
+Flag and fix ViewModels that:
+- are too large
+- contain excessive private helper methods
+- mix UI state, business rules, mapping, analytics, validation, networking coordination, and navigation decisions all together
+- are hard to test in isolation
+- own responsibilities that should be extracted
+
+When reviewing ViewModels:
+- identify responsibilities
+- extract business rules to domain layer
+- extract mapping to mapper classes/files
+- extract validation logic
+- extract reusable state logic where appropriate
+- keep the ViewModel focused on intent handling, state exposure, and coordination
+
+### 3. Separation of concerns and project pattern fit
+
+Check that responsibilities are cleanly separated across:
+- UI / presentation
+- state holder / ViewModel / presenter
+- domain / use cases / business rules
+- data / repository / API / persistence
+- mapping / adapter layers
+- navigation
+- design-system reusable components
+
+Watch for:
+- feature modules reaching into each other improperly
+- UI code directly depending on transport/network models
+- shared abstractions being bypassed
+- platform-specific code leaking into common code without a good reason
+- helper code copied into feature modules instead of reused from the correct shared location
+- local changes that quietly introduce a competing pattern
+
+Prefer review against the existing project architecture, not an imaginary rewrite. Flag meaningful drift, but prefer incremental improvements over speculative redesign.
+
+---
+
+## Model and boundary integrity
+
+Review whether each architectural layer uses the correct model type.
+
+Flag:
+- DTOs escaping the data layer
+- domain models polluted with UI or persistence concerns
+- screen logic depending directly on backend response shapes
+- excessive reuse of one model across unrelated layers
+- unclear or duplicated mapping responsibilities
+- “god models” reused everywhere for convenience
+- persistence entities leaking into presentation or domain without clear justification
+
+Prefer:
+- transport/data models in the data layer
+- domain models for business concepts
+- UI models for screen-specific rendering needs when appropriate
+- explicit mappers/adapters in predictable locations
+- clear ownership of mapping logic
+
+Also review whether:
+- nullability is modeled intentionally
+- optional fields are handled in the right layer
+- unknown enum or backend values are tolerated where appropriate
+- conversion between models is testable and discoverable
+
+---
+
+## State management correctness
+
+Review state design for clarity, predictability, and testability.
+
+Flag:
+- contradictory state flags
+- impossible state combinations
+- one-off effects modeled as persistent state incorrectly
+- state updated from too many sources without clear ownership
+- event handling that risks replay or duplication
+- ad hoc mutation patterns that are hard to reason about
+- state models that mix durable UI state with transient navigation/toast/snackbar effects
+- partial updates that can produce invalid state
+- mutable state leaked beyond its owner
+
+Prefer:
+- explicit state models
+- predictable state transitions
+- clear separation between durable UI state and transient effects
+- reducers/state transformers where complexity justifies them
+- state ownership that is easy to trace and test
+- immutable public state surfaces
+
+Review whether:
+- loading, success, empty, error, partial-data, and retry states are modeled appropriately
+- submit/refresh/load-more/restore flows can coexist safely
+- stale data and fresh data interactions are intentional
+- event-triggered state changes are deterministic
+
+---
+
+## Dependency injection and object lifetime
+
+Review whether dependencies are created and scoped correctly.
+
+Flag:
+- direct instantiation of significant collaborators in feature code
+- singleton use where narrower scoping is more appropriate
+- stateful objects shared too broadly
+- lifecycle mismatches between owner and dependency
+- hidden service locators or ad hoc dependency access
+- dependencies that make testing harder because construction is implicit
+- objects whose lifetime is longer than their owning feature actually needs
+
+Prefer:
+- explicit dependency injection
+- correct scoping aligned with lifecycle and ownership
+- clear dependency graphs
+- easily testable construction paths
+- narrow object lifetime where possible
+
+Also review:
+- whether dispatchers are injected where the project expects it
+- whether stateful caches, coordinators, or managers are scoped appropriately
+- whether shared instances can accidentally leak state between features or sessions
+
+---
+
+## Persistence, caching, and source-of-truth discipline
+
+Review how data is cached, persisted, and refreshed.
+
+Flag:
+- unclear source of truth
+- duplicated state across memory, persistence, and UI without clear ownership
+- stale cache risks
+- persistence details leaking into unrelated layers
+- optimistic updates without reconciliation strategy
+- missing invalidation or refresh logic where needed
+- local and remote state merged ad hoc in ViewModel/UI
+- persistence models used too broadly across layers
+- refresh logic that depends on hidden assumptions
+- silent fallback to stale or partial data without a visible contract
+
+Prefer:
+- explicit source-of-truth decisions
+- predictable refresh behavior
+- narrow persistence boundaries
+- cache behavior that is understandable and testable
+- reconciliation strategies for optimistic or partial updates
+- clear invalidation semantics
+
+Review whether:
+- retries can create duplicate writes
+- partial failures leave local state inconsistent
+- local state can be restored safely after process death if relevant
+- pagination state and cache state are coordinated sensibly
+
+---
+
+## Security and privacy review
+
+Review the implementation for client-side security, privacy, and trust-boundary issues.
+
+Flag:
+- secrets or tokens handled insecurely
+- sensitive data logged, cached, or exposed unnecessarily
+- role/permission checks enforced only in UI
+- unsafe assumptions about backend authorization
+- external input used without validation
+- unsafe deep link, URL, WebView, file, or URI handling
+- raw backend/internal error details exposed to users
+- auth/session edge cases that could leak data or leave stale privileged state
+- insecure local storage of sensitive values
+- PII passed through layers that do not need it
+- admin or privileged behaviors insufficiently isolated
+- trusting client-side state for authorization-sensitive behavior
+- user-supplied content rendered or routed without sufficient sanitization/validation
+- sensitive values included in analytics or crash reporting
+
+Prefer:
+- minimal exposure of sensitive data
+- privacy-safe logging
+- explicit trust-boundary handling
+- defensive parsing/validation of external input
+- clear separation between UX gating and real authorization
+- secure session cleanup and recovery behavior
+- least-privilege handling of sensitive fields
+
+Review also for:
+- stale session state after logout
+- cached privileged data remaining visible to a lower-privilege user
+- assumptions that hidden UI equals protected behavior
+- permissive WebView/navigation/deep link handling
+- unvalidated IDs or routes flowing across boundaries
+
+---
+
+## Shared UI system enforcement
+
+The code must follow the existing project design system and shared UI conventions.
+
+Review for correct use of:
+- existing shared components already present in the codebase
+- spacing tokens instead of raw dimensions where tokens should be used
+- shared typography and theme styles
+- approved app color usage
+- existing design patterns and shared building blocks before creating new UI primitives
+
+Flag and fix:
+- direct use of generic Compose primitives when a project abstraction already exists
+- inconsistent spacing values
+- ad hoc styling
+- hardcoded dimensions when tokens/components already exist
+- UI duplication that should be extracted into reusable components
+- mixing feature-specific styling with shared design-system responsibilities
+- ignoring existing shared layout/content/error/loading patterns
+
+When a reusable pattern appears more than once, consider extracting a component, but do not over-abstract prematurely.
+
+Review whether:
+- the code uses shared UI conventions consistently across states
+- empty/loading/error states match existing patterns
+- visual hierarchy and interaction patterns feel like the rest of the app
+- reusable UI logic is placed in the correct shared layer
+
+---
+
+## Strings and localization rules
+
+Do not allow hardcoded user-facing strings.
+
+Review for:
+- strings that should go into resource files
+- default text that does not match the project’s language/tone conventions
+- missing localization wiring
+- feature code with embedded labels, button text, titles, placeholders, errors, or toasts/snackbars
+- user-facing strings created in ViewModels/repositories/domain logic when they belong in presentation/resources
+- raw backend strings surfaced to users
+
+Prefer:
+- string resources
+- the project’s default product tone and language unless requirements say otherwise
+- parameterized string resources where dynamic data is involved
+- presentation-level formatting for user-facing values
+
+Review whether:
+- number/date/currency formatting is done in the correct layer
+- pluralization and parameterized messages are handled properly
+- fallback text is product-appropriate rather than developer-centric
+
+---
+
+## Compose review rules
+
+### 1. Avoid unnecessary recomposition
+
+Review composables for recomposition and rendering inefficiencies.
+
+Flag and fix:
+- unstable parameters passed unnecessarily
+- creation of heavy objects during recomposition
+- repeated sorting/filtering/mapping directly inside composables when it should be precomputed
+- lambdas recreated unnecessarily where it creates avoidable churn
+- reading broad state when only a small subset is needed
+- large composables doing too much work in one place
+- state hoisting problems
+- missing memoization where appropriate
+- derived UI data recalculated repeatedly in composition
+- unnecessary use of `collectAsState` / state observation too high in the tree
+- expensive formatting or resource selection repeated for each recomposition
+
+Check for opportunities to use:
+- smaller composables
+- state hoisting
+- `remember` when appropriate
+- `derivedStateOf` when appropriate
+- stable UI models
+- immutable collections/models where the project pattern supports it
+
+Do not mechanically add `remember` everywhere. Only use it when it improves correctness or performance.
+
+### 2. Side effects correctness
+
+Review use of:
+- `LaunchedEffect`
+- `DisposableEffect`
+- `SideEffect`
+- `rememberCoroutineScope`
+- `snapshotFlow`
+
+Flag:
+- incorrect keys
+- effects restarting unnecessarily
+- launching work from composition without the right lifecycle handling
+- collecting flows in the wrong place
+- stale captured values
+- lifecycle leaks
+- side effects coupled too tightly to rendering code
+- event consumption patterns that risk duplicate effects
+
+### 3. Lazy layouts and lists
+
+Review lists for:
+- stable keys
+- item content separation
+- expensive per-item computation
+- missing extraction of list item composables
+- nested scrolling/performance traps
+- avoidable recomposition of whole lists
+- unstable item models
+- inline derived state repeated across rows
+
+### 4. State observation placement
+
+Review whether state is observed at the right level in the tree.
+
+Flag:
+- collecting large screen state at the top and passing broad state everywhere
+- child composables receiving more state than they need
+- direct repository or use case reads inside composables
+- UI observing raw data flows that should already be shaped by the state holder
+
+### 5. Compose API hygiene
+
+Review composable APIs for:
+- too many parameters
+- mixed concerns
+- unstable or mutable inputs
+- callbacks that are ambiguous or easy to misuse
+- booleans controlling many branches instead of clearer UI models
+
+---
+
+## Coroutine and threading review rules
+
+Be strict here.
+
+### 1. Dispatcher/threading correctness
+
+Review whether work is executed on the correct dispatcher/thread.
+
+Flag and fix:
+- blocking or expensive work on main thread
+- ambiguous threading for IO/network/database/heavy mapping
+- CPU-heavy transformations done in UI/ViewModel on main
+- missing dispatcher injection where the project expects testable dispatching
+- accidental thread hopping that adds complexity without benefit
+
+Review whether:
+- heavy mapping or sorting is done too late in the pipeline
+- background work returns to main only where necessary
+- thread decisions are visible and testable
+
+### 2. Cancellation correctness
+
+Review coroutine usage to ensure cancellation is handled properly.
+
+Flag:
+- swallowing cancellation accidentally
+- broad `catch` blocks that intercept cancellation incorrectly
+- long-running loops without cancellation awareness
+- operations that ignore structured concurrency
+- child jobs launched in a way that can leak or outlive expected scope
+
+Make sure:
+- cancellation exceptions are not incorrectly converted into generic failures
+- concurrent work is scoped correctly
+- work is tied to lifecycle-appropriate scopes
+- `supervisorScope` / `coroutineScope` usage is intentional
+
+### 3. Exception handling
+
+Review for:
+- silent failures
+- overbroad `try/catch`
+- missing recovery paths
+- mixing domain errors with transport errors with UI errors without clear mapping
+- exceptions converted to vague generic states without observability
+- blanket `runCatching` misuse that hides failure semantics
+- fallback behavior that masks real faults
+
+Prefer:
+- explicit error mapping
+- domain-level error types where appropriate
+- preserving cancellation semantics
+- avoiding blanket `runCatching` misuse if it obscures failure paths
+
+### 4. Flow and async stream correctness
+
+Review usage of:
+- `Flow`
+- `StateFlow`
+- `SharedFlow`
+
+Flag:
+- wrong hot vs cold stream choice
+- unnecessary multiple collectors
+- replay/buffer misuse
+- `stateIn` / `shareIn` misuse
+- expensive transformations duplicated across collectors
+- collecting streams in the UI when state should already be prepared in ViewModel/presenter
+- mutable streams exposed publicly
+- event streams configured in ways that risk replay bugs or dropped events
+
+Review whether:
+- stream ownership is clear
+- collector lifetimes match feature lifetimes
+- sharing policy is intentional
+- expensive upstream transformations are not repeated needlessly
+
+---
+
+## Concurrency and race-condition safety
+
+Review for race conditions and coordination issues beyond basic coroutine correctness.
+
+Flag:
+- duplicate submissions from repeated taps/events
+- stale responses overriding newer state
+- concurrent jobs mutating the same state unsafely
+- missing debounce/throttle where user input can trigger repeated work
+- non-idempotent actions with weak protection against retries
+- refresh/load interactions that can produce inconsistent UI state
+- multiple async paths updating shared state without deterministic ordering
+- retry flows that can replay destructive actions unsafely
+- latest-wins vs first-wins behavior left accidental
+- multiple requests for the same resource without coordination
+
+Prefer:
+- explicit coordination of concurrent work
+- latest-wins or first-wins behavior chosen intentionally
+- duplicate-action protection where needed
+- deterministic state updates under concurrency
+- idempotent or safely guarded submit flows where appropriate
+
+Review whether:
+- concurrent pagination and refresh can conflict
+- optimistic UI and server confirmation can race
+- restored state can be overwritten by slow in-flight work
+- repeated navigation or effect dispatch can happen from racing state paths
+
+---
+
+## Reusability and file organization
+
+### 1. Reusable components
+
+Check whether repeated UI patterns, validation rules, mappers, or helper logic should be extracted.
+
+Flag:
+- duplicated UI blocks
+- repeated transformation logic
+- repeated validation logic
+- ad hoc extension functions scattered in the wrong place
+- duplicated sealed state handling patterns
+- slightly different copies of the same business rule across features
+
+Prefer reusable extraction only when:
+- duplication is real
+- naming can be clear
+- abstraction improves maintainability
+
+### 2. Classes in their own files
+
+Classes, interfaces, mappers, validators, reducers, and reusable components should usually live in their own files when they are meaningful standalone units.
+
+Flag:
+- large files with many unrelated classes
+- nested declarations that reduce discoverability
+- helper classes buried inside large files without a strong reason
+- feature files that combine UI, mapping, and orchestration
+
+Do not split tiny private helpers into separate files unless it materially improves structure.
+
+### 3. File size and complexity
+
+Review for:
+- long files
+- long methods
+- high cyclomatic complexity
+- deep nesting
+- “private helper graveyards”
+- unclear grouping of related logic
+
+Prefer:
+- focused files
+- discoverable naming
+- cohesive grouping of responsibilities
+- extracted helpers only when they meaningfully improve structure
+
+---
+
+## Internal API design quality
+
+Review the design of functions, classes, and module interfaces as internal APIs.
+
+Flag:
+- broad or ambiguous function signatures
+- boolean parameter smells
+- methods with too many responsibilities
+- mutable public surfaces where immutability is preferable
+- APIs that leak implementation details to callers
+- poor naming or unclear ownership
+- function parameters that require callers to understand too much internal detail
+- extension functions in surprising or inappropriate layers
+- command/query responsibilities mixed into a single confusing API
+- callbacks whose ordering or contract is unclear
+
+Prefer:
+- narrow and intention-revealing interfaces
+- cohesive responsibilities
+- immutability by default
+- clear ownership and discoverability
+- APIs that are easy to call correctly and hard to misuse
+
+Review whether:
+- abstraction boundaries match real usage
+- public methods expose too many low-level details
+- naming reflects business intent rather than implementation details
+
+---
+
+## Static analysis and code quality expectations
+
+Review with Kotlin linting and static analysis standards in mind.
+
+Check for issues that would matter to tools such as:
+- ktlint
+- detekt
+- Android/Kotlin lint
+- Compose-specific static analysis where relevant
+
+Review for:
+- overly long methods
+- overly long files
+- high cyclomatic complexity
+- magic numbers
+- poor naming
+- excessive nesting
+- nullable misuse
+- misuse of scope functions
+- hidden side effects
+- dead code
+- unused parameters/imports/helpers
+- weak visibility modifiers
+- extension functions placed in the wrong layer
+- inconsistent naming with the surrounding codebase
+
+Even if tools are not run yet, review as if the code should pass serious static analysis.
+
+---
+
+## Testing expectations
+
+Review testability and gaps, even if tests were not requested.
+
+Check whether the implementation should have:
+- unit tests for domain logic
+- mapper tests
+- validator tests
+- reducer/state transformation tests
+- ViewModel tests for important state transitions
+- repository tests where nontrivial mapping/orchestration exists
+- concurrency or race-condition tests where multiple async paths exist
+- serialization or parsing tests when boundary handling is important
+- snapshot/state rendering tests if the project uses them for meaningful UI states
+
+Flag:
+- logic hidden in composables that is hard to test
+- code coupled too tightly to platform APIs
+- missing abstraction seams that prevent testing
+- high-risk logic shipped without test coverage
+- no tests around failure/retry/empty/partial-data behavior
+- no tests around duplicate-action protection or race-prone flows
+
+Do not demand tests for trivial wiring, but do flag missing tests for meaningful logic.
+
+---
+
+## Observability and diagnosability
+
+Review whether the implementation will be understandable in production when things go wrong.
+
+Flag:
+- silent failures
+- unstructured or low-value logging
+- missing context around high-risk operations
+- excessive logging noise
+- logging of sensitive data
+- critical user flows with no useful diagnostic signals
+- errors swallowed without analytics, logs, or surfaced state
+- diagnostics that are impossible to correlate with the failing feature path
+- no distinction between expected degraded states and genuine faults
+
+Prefer:
+- meaningful structured logs
+- clear error propagation
+- diagnostics around important flows
+- privacy-safe logging and analytics
+- enough context to understand failures without leaking sensitive information
+
+Review whether:
+- submit flows are traceable
+- retry/failure states can be correlated with logs
+- analytics events avoid leaking sensitive payloads
+- production failures can be tied back to a specific feature path
+
+---
+
+## Navigation, state, and data handling review
+
+Check that:
+- navigation decisions are not scattered inconsistently
+- state models are explicit and predictable
+- loading/empty/error/success states are handled
+- partial or missing backend data is handled safely
+- role/permission/session-dependent behavior is not assumed blindly
+- optimistic assumptions about backend fields are avoided
+- nullability is handled intentionally, not defensively everywhere
+- effect dispatch does not create duplicate navigation or repeated snackbars
+
+Flag:
+- navigation logic mixed unpredictably across UI and state holder
+- brittle route assumptions
+- state transitions that can trigger duplicate navigation
+- UI paths that assume backend completeness
+
+---
+
+## Backward compatibility and migration safety
+
+Review whether the implementation is resilient to evolving schemas, partial rollouts, and app upgrades.
+
+Flag:
+- brittle enum/string assumptions
+- code that assumes fields are always present
+- serialization changes that may break older persisted data
+- local model changes without migration consideration
+- non-defensive parsing of backend responses
+- assumptions that all clients/backends are upgraded simultaneously
+- logic that breaks when unknown enum values or new fields appear
+- all-or-nothing rollout assumptions
+- old cached state that becomes unreadable or misinterpreted
+
+Prefer:
+- tolerant readers where appropriate
+- explicit handling of unknown/missing values
+- rollout-safe behavior
+- migration-aware persistence changes
+- defensive parsing at boundaries
+
+Review whether:
+- fallback behavior is defined for new/unknown backend values
+- feature flags or capability checks degrade safely
+- storage changes require migration paths
+
+---
+
+## Accessibility and UX robustness
+
+Review whether the implementation is robust and understandable for users across normal and degraded states.
+
+Flag:
+- unclear loading, empty, or error handling
+- actions without feedback
+- color-only communication of meaning
+- poor accessibility semantics where relevant
+- fragile flows under slow network or partial data
+- retry/recovery paths that are missing or unclear
+- confusing disabled states
+- degraded-state UX that leaves the user stuck without guidance
+- no distinction between “no data yet” and “failed to load”
+- inaccessible click targets or semantics where the platform supports better patterns
+
+Prefer:
+- explicit user feedback
+- resilient degraded-state UX
+- accessible semantics where supported by the platform/pattern
+- clear retry and recovery behavior
+- recoverable user paths under partial backend failure
+
+---
+
+## Rollout and feature isolation readiness
+
+Review whether the implementation is safe to release incrementally.
+
+Flag:
+- unfinished dependencies wired as hard requirements
+- weak handling of unavailable backend capabilities
+- no clear isolation for risky new flows
+- assumptions that everything is enabled simultaneously
+- code paths that cannot degrade safely if partial rollout occurs
+- no capability checks where the backend may lag behind the client
+- new flows tightly coupled to unrelated existing flows
+
+Prefer:
+- graceful degradation
+- clear feature boundaries
+- rollout-safe assumptions
+- safe handling of partially available functionality
+- explicit feature capability handling where relevant
+
+---
+
+## KMP-specific review concerns
+
+Because this is a KMP project, check for:
+- unnecessary platform divergence
+- common code that should remain common
+- platform-specific logic introduced without justification
+- APIs that reduce portability
+- abstractions that will make iOS/Android behavior inconsistent
+- threading assumptions that do not hold well across targets
+- shared code using APIs that complicate testing or platform compatibility
+- platform differences hidden in ways that make behavior hard to reason about
+- shared logic depending indirectly on platform-only behavior
+- platform abstractions that are too wide or too leaky
+
+If the implementation starts changing source-set placement, shared-vs-platform ownership, or target-specific architectural boundaries, say that the change should also be reviewed with `kotlin-project-architecture-review`.
+
+---
+
+## Documentation and discoverability
+
+Review whether the code is understandable to future maintainers.
+
+Flag:
+- non-obvious decisions with no explanation
+- reusable abstractions with unclear intended usage
+- surprising constraints hidden in implementation details
+- high-value architectural choices that are undocumented
+- naming that makes responsibilities or ownership hard to discover
+- behavior that depends on invariants not visible at call sites
+
+Prefer:
+- concise comments for non-obvious decisions
+- discoverable naming
+- lightweight documentation where it materially helps future work
+- clear file and type organization
+
+---
+
+## How to conduct the review
+
+### Step 1: Understand the change
+Identify:
+- which feature/module changed
+- the architectural path of the feature
+- what responsibilities are present
+- where business logic is currently placed
+- whether the implementation fits existing patterns
+- the risk areas for security, concurrency, persistence, and maintainability
+- whether the change is local implementation work or is pushing into structural architecture territory
+
+### Step 2: Review in categories
+Review at minimum:
+1. Architecture / layering
+2. ViewModel size and responsibilities
+3. Domain/business logic placement
+4. Model and boundary integrity
+5. State correctness
+6. DI and lifetime management
+7. Persistence/cache/source-of-truth
+8. Security/privacy
+9. UI system/design consistency
+10. Compose recomposition/performance
+11. Coroutine/threading/cancellation/exception handling
+12. Concurrency/race conditions
+13. Reusability / duplication / file organization
+14. Internal API quality
+15. Testability and tests
+16. Observability/diagnostics
+17. Static-analysis quality
+18. Localization / strings
+19. Backward compatibility / migration safety
+20. Accessibility / UX robustness
+21. Rollout safety
+22. KMP portability / shared-vs-platform concerns
+
+### Step 3: Prefer minimal, high-value fixes
+Do not rewrite the entire feature unless the implementation is fundamentally broken.
+
+Prefer:
+- targeted improvements
+- extractions that reduce complexity
+- moving logic to the right layer
+- improving naming and file organization
+- correcting threading/error-handling issues
+- extracting reusable components
+- reducing recomposition risk
+- strengthening security and trust-boundary handling
+- improving resilience under partial data, retries, and race conditions
+
+### Step 4: Escalate structural issues when needed
+If the implementation appears to materially change:
+- module boundaries
+- source-set placement
+- shared vs platform boundaries
+- navigation architecture
+- manifest/exported entry points
+- Android entry-point ownership
+- feature-level source of truth ownership
+
+then state clearly that the PR/code review should also be evaluated with `kotlin-project-architecture-review`.
+
+### Step 5: Summarize findings clearly
+When reporting or reviewing, structure the output as:
+1. High-risk issues
+2. Security and privacy issues
+3. Architectural issues
+4. State/model boundary issues
+5. Performance issues
+6. Coroutine/threading/concurrency issues
+7. Persistence/source-of-truth issues
+8. UI/design-system/localization issues
+9. Maintainability issues
+10. Test gaps
+11. Suggested fixes
+12. Optional follow-up refactors
+13. Whether architecture-review escalation is needed
+
+Be explicit about severity and impact.
+
+---
+
+## Fix rules
+
+If asked to apply fixes:
+- apply only necessary and justified fixes
+- preserve the existing architecture
+- do not introduce broad unrelated refactors
+- keep diffs understandable
+- do not create abstractions that are more complex than the problem
+- prefer incremental improvement
+- do not weaken security, observability, or testability for the sake of brevity
+- if a structural issue exists, fix locally where possible but explicitly call out larger architectural follow-up separately
+
+---
+
+## Anti-patterns to flag aggressively
+
+- massive ViewModels
+- business logic in composables
+- business logic heavily embedded in ViewModels
+- raw DTOs used directly in UI
+- repeated inline mapping logic
+- hardcoded strings
+- hardcoded spacing/styling values that should use design tokens/components
+- unnecessary recompositions
+- collecting too much state too high in the tree
+- blocking work on main thread
+- broad exception swallowing
+- swallowing cancellation
+- unstructured coroutines
+- race conditions between refresh/load/submit paths
+- feature code bypassing shared design system
+- large files containing unrelated responsibilities
+- duplicated UI/components that should be shared
+- introducing parallel patterns instead of reusing established ones
+- insecure token/session handling
+- sensitive data in logs or analytics
+- permission checks only in UI
+- unclear source of truth
+- brittle parsing or schema assumptions
+- APIs that are easy to misuse
+- hidden state transitions or replay-prone transient events
+- local implementation changes that quietly introduce architecture drift
+
+---
+
+## Final instruction
+
+Review like an architect who will have to maintain this code for years.
+
+Be strict about:
+- correctness
+- scalability
+- maintainability
+- consistency
+- performance
+- architecture boundaries
+- security
+- privacy
+- diagnosability
+- rollout safety
+
+Optimize for protecting the codebase.

+ 108 - 0
.claude/skills/kotlin-kmp-refactor-safety/SKILL.md

@@ -0,0 +1,108 @@
+---
+name: kotlin-kmp-refactor-safety
+description: Refactor discipline for existing codebases. Enforces scope control, migration safety, compatibility, observability, and tests to keep refactors reviewable and low-risk.
+license: Apache-2.0
+metadata:
+  version: "1.0.0"
+---
+
+# KMP Refactor Safety Skill
+
+Use this skill when the task is primarily a refactor, migration, reliability hardening, or architectural cleanup of existing code (not a greenfield feature).
+This skill is about **change discipline** and **reviewability**, not system architecture itself. Pair it with your architecture/style/testing skills as needed.
+
+## Primary objective
+
+Deliver refactors that are:
+- minimal in surface area
+- safe to review and merge
+- free of parallel/competing implementations
+- regression-resistant (tests + observability)
+
+## Non-negotiables
+
+### 1) Scope control (no opportunistic rewrites)
+- Touch only files required to achieve the goal.
+- Do not reformat, rename, or “clean up” unrelated code.
+- Avoid broad mechanical changes unless explicitly requested.
+- Keep diffs small and intention-revealing.
+
+### 2) No parallel implementations
+- Do not introduce a new pattern while leaving the old pattern active.
+- End state must be one of:
+  - old path removed, OR
+  - old path gated behind a **feature flag** with a clear removal plan (explicit TODO + tracking issue).
+- Avoid duplicate sources of truth. If ownership must move, move it once and rewire callers deterministically.
+
+### 3) Preserve public contracts unless required
+- Keep public APIs stable (interfaces, model shapes, routing/public endpoints, configuration surface).
+- If a contract must change:
+  - update all call sites in the same change-set
+  - document the breaking change in PR notes/release notes
+  - add targeted tests proving compatibility and intent
+
+### 4) Refactor in safe phases
+Prefer this order:
+1. **Foundation**: introduce the new core abstraction (e.g., manager/store/service) and wire it without changing behavior.
+2. **Adoption**: migrate existing call sites incrementally to the new abstraction.
+3. **Lock-in**: remove the old path or gate it behind a flag (default off) and make the new path the default.
+4. **Cleanup**: delete dead code, consolidate configuration, reduce complexity.
+
+Never ship an ambiguous half-state where both old and new paths may run without a flag and clear routing.
+
+### 5) Observability (redacted)
+For flows prone to edge cases (auth, payments, retries, background/foreground, webhooks, state restoration):
+- add logs/metrics around transitions and decisions
+- never log secrets (tokens, passwords, codes, personal data)
+- log only:
+  - booleans/branch decisions
+  - status codes and operation names
+  - hashed or truncated IDs if needed (e.g., last 4 chars)
+- ensure logs can be disabled or are appropriate for production environments
+
+### 6) Tests are part of the refactor
+Minimum expectations:
+- unit tests for pure logic/state transitions/mappers
+- regression tests for the bug or failure mode motivating the refactor
+- coverage for:
+  - success path
+  - failure path
+  - concurrency/race behavior when applicable (mutex, retry, idempotency)
+- avoid brittle tests; prefer deterministic inputs/outputs and stable assertions
+
+### 7) Idempotency and re-entrancy for callbacks
+For events that can repeat (callbacks, deep links, webhooks, background resume):
+- handle duplicates safely
+- validate inputs before side effects
+- operations should be safe to retry without corrupting state
+
+### 8) Backwards compatibility and migration safety
+- Use versioning or feature flags for behavior changes that might break existing users.
+- Provide migration steps (data migrations, config updates) in a single place.
+- If data shape changes:
+  - define transitional support window
+  - ensure rollback strategy (or non-destructive migrations)
+
+### 9) Performance and resource safety
+- Avoid adding disk/network reads on hot paths (e.g., per-request storage reads).
+- Prefer caching with explicit invalidation when correctness requires it.
+- Ensure retries are bounded (max 1 retry unless specified) and do not create loops.
+
+### 10) PR hygiene (reviewable output)
+- Prefer small, coherent commits with clear messages.
+- Avoid reordering code blocks unless necessary.
+- Include PR notes:
+  - What changed
+  - Why
+  - How to test
+  - Flags/migration steps
+  - Rollback plan (if relevant)
+
+## Quick refactor checklist
+- [ ] Single source of truth after refactor
+- [ ] No stale in-memory state alongside persisted state (unless intentionally synchronized)
+- [ ] No competing interceptors/validators/plugins that overwrite each other
+- [ ] Duplicate events/callbacks are handled idempotently
+- [ ] Failure modes are explicit and do not cause silent data loss/logouts
+- [ ] Tests cover the motivating regression and key edge cases
+- [ ] Clear migration/flag/rollback notes included

+ 410 - 0
.claude/skills/kotlin-navigation-compose-multiplatform/SKILL.md

@@ -0,0 +1,410 @@
+---
+name: kotlin-navigation-compose-multiplatform
+description: Use when designing, implementing, or reviewing navigation in Compose Multiplatform projects — route modeling, back stack ownership, argument passing, NavOptions, conditional flows, deep links, and adaptive navigation UI.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "1.2.0"
+---
+
+# Compose Multiplatform Navigation
+
+Use this skill when designing, implementing, or reviewing navigation in a Compose Multiplatform project.
+
+This skill is intentionally strict. Its purpose is to keep route modeling coherent, back stack behavior explicit, argument passing minimal, navigation side effects controlled, and multiplatform routing boundaries clean across Android and shared Compose code.
+
+> **Navigation library scope**: This skill covers **Jetpack Navigation 2** and its Compose Multiplatform equivalent — the current stable navigation library. **Navigation 3** (Jetpack Navigation 3, a ground-up redesign) was in alpha as of mid-2025 and has a substantially different back-stack model, entry API, and lifecycle integration. If your project uses Navigation 3, verify which guidance applies before proceeding. Navigation 3 status: https://developer.android.com/guide/navigation/navigation3
+
+## Primary goals
+
+The navigation design should optimize for:
+
+- clear route ownership
+- explicit back stack ownership
+- controlled navigation side effects
+- minimal and stable argument passing
+- predictable back-stack behavior
+- conditional flows that preserve user context
+- coherent deep-link handling
+- separation between shared routing logic and platform entry concerns
+- adaptive navigation chrome for different window sizes
+- previewable and testable navigation-hosting UI
+
+Do not treat navigation as only “go from screen A to screen B”.
+Treat it as app-state movement with user-history consequences.
+
+## Navigation library version note
+
+This skill covers Compose Multiplatform Navigation 2 (current stable as of mid-2025), which is the `navigation-compose` integration used in most KMP projects today.
+
+Jetpack Navigation 3 (a significant redesign with a different back-stack model built around `NavDisplay` and `NavEntry`) was in alpha as of mid-2025. If the project is using Navigation 3, some of this guidance — particularly around `NavController` ownership, `popUpTo`, and argument passing — applies differently. Verify against current Navigation 3 documentation before applying this skill to a Navigation 3 project.
+
+## Navigation library version scope
+
+This skill covers **Jetpack Navigation 2** (stable) as used in Compose Multiplatform, including the `compose-navigation` artifact and the `NavHost`/`NavController` model. **Navigation 3** (a separate alpha-stage redesign as of mid-2025) uses a fundamentally different back-stack model and is not covered here. If the project uses Navigation 3, verify that the patterns below still apply — some will, some will not. Check the [Navigation 3 documentation](https://kotlinlang.org/docs/multiplatform/compose-navigation-3.html) for current guidance.
+
+## Official defaults to prefer
+
+Unless the project has a strong reason not to, prefer:
+
+- a single owner of `NavController` or equivalent navigation state
+- event-based navigation from composables instead of passing `NavController` downward
+- typed or otherwise structured routes
+- minimal argument payloads, usually identifiers rather than complex objects
+- explicit use of back-stack options like `popUpTo`, `inclusive`, `saveState`, `restoreState`, and `launchSingleTop`
+- conditional navigation driven by shared state rather than duplicated guards
+- deep-link patterns that are non-overlapping and predictable
+- shared route interpretation in common code when valid across targets
+- browser URL binding only at the platform/web edge
+
+---
+
+## Review dimensions
+
+### 1. Route modeling
+
+Check whether routes are modeled clearly and intentionally.
+
+Prefer:
+- route models that are explicit and typed where possible
+- destination identity that is understandable from the code
+- route definitions separated from screen implementation details
+
+In Compose Multiplatform navigation, a route identifies a destination and defines the arguments required to navigate there, while staying separate from the UI implementation.
+
+Flag as a concern when:
+- routes are brittle string literals scattered through the app
+- route parameters are implicit or weakly structured
+- screen identity and navigation actions are hard to trace
+
+### 2. NavController ownership and navigation events
+
+Android explicitly recommends that composables expose navigation events rather than receiving a `NavController` reference directly.
+
+Check whether:
+- the navigation host or app shell owns the `NavController`
+- lower composables expose callbacks like `onOpenDetails()` rather than calling `navigate()` directly
+- `navigate()` is called from callbacks or controlled side effects, not from normal rendering paths
+
+Flag as a concern when:
+- `NavController` is passed deep into leaf composables
+- leaf composables call `navigate()` directly
+- navigation can be retriggered on recomposition
+
+### 3. Back stack ownership
+
+Navigation state should have a clear owner.
+
+Check whether:
+- the back stack is owned by one clear navigation state holder
+- navigation mutations happen through a small number of controlled paths
+- unrelated screens or helpers are not mutating navigation state ad hoc
+
+Flag as a concern when:
+- many layers can push and pop destinations freely
+- back stack state is effectively global without clear ownership
+- destination changes are triggered from rendering code without architectural control
+
+### 4. Basic navigation policy
+
+Android’s Navigation docs treat `navigate()` and `popBackStack()` as core app-history operations.
+
+Check whether:
+- forward navigation uses the right destination key or typed route
+- back navigation uses `popBackStack()` or `navigateUp()` intentionally
+- failed `popBackStack()` cases are handled safely instead of leaving the app in an invalid state
+
+Flag as a concern when:
+- back behavior is assumed rather than designed
+- popping the last destination can leave a blank screen with no recovery path
+- history behavior is inconsistent across similar flows
+
+### 5. NavOptions and stack-shaping rules
+
+Review the explicit use of navigation options.
+
+Important options include:
+- `popUpTo()`
+- `inclusive`
+- `saveState`
+- `restoreState`
+- `launchSingleTop`
+
+Check whether:
+- `popUpTo()` is used to shape history intentionally
+- `inclusive` is used only when removing the target destination is correct
+- `saveState` / `restoreState` are used where returning to preserved stacks matters
+- `launchSingleTop` is used to avoid duplicate top destinations
+
+Flag as a concern when:
+- duplicate destinations accumulate unnecessarily
+- history is rewritten accidentally
+- stack state should be preserved but is discarded
+- `popUpTo()` behavior is hard to reason about
+
+### 6. Argument-passing discipline
+
+Android recommends passing the minimum necessary information and avoiding complex object transfer through navigation arguments.
+
+Check whether:
+- arguments are small and stable
+- identifiers are passed instead of whole UI/data models
+- destinations load their own data from minimal navigation input
+- route serialization stays understandable
+
+Flag as a concern when:
+- complex objects are passed through navigation
+- route payloads become a substitute for state ownership
+- process recreation would be fragile because arguments are oversized or inconsistent
+
+### 7. Conditional navigation
+
+Conditional navigation should be modeled through shared state and explicit transitions, not scattered checks.
+
+Check whether:
+- auth gates, onboarding gates, permission gates, or result-based flows are modeled through shared state
+- returning from a conditional flow preserves user context
+- prior back-stack entries or saved state are used intentionally where results must be returned
+
+Flag as a concern when:
+- login or gating logic is duplicated across many screens
+- guarded destinations redirect in ways that lose user context unnecessarily
+- success/failure results from intermediate flows are not modeled clearly
+
+### 8. State hoisting and one-off effects
+
+Navigation is a side effect and should be coordinated with hoisted state.
+
+Check whether:
+- navigation-related state is hoisted when multiple composables need to coordinate it
+- persistent state is separated from one-time navigation effects
+- side effects are not fired from unstable rendering branches
+
+Flag as a concern when:
+- navigation decisions are trapped inside leaf composables
+- events and persistent UI state are mixed together carelessly
+- recomposition can retrigger navigation
+
+### 9. Deep links
+
+Compose Multiplatform navigation supports destination deep links through `NavDeepLink` patterns.
+
+Check whether:
+- deep-link patterns are explicit and non-overlapping
+- placeholders are used intentionally
+- required path parameters and optional query parameters are modeled clearly
+- the same URI pattern is not claimed by multiple destinations
+
+Flag as a concern when:
+- deep-link patterns intersect
+- route matching is ambiguous
+- deep-link handling depends on accidental ordering
+
+### 10. Multiplatform routing and browser URL binding
+
+On web, Compose Multiplatform can bind navigation state to browser history and URL fragments.
+
+Check whether:
+- browser URL binding happens at the web entry point via `bindToBrowserNavigation()` (this API is part of the Compose Multiplatform web-target navigation layer; verify its current stability status against the Compose Multiplatform release notes before treating it as stable API)
+- route-to-URL translation is explicit where readability matters
+- `@SerialName` or custom route mapping is used when default generated URLs are too implementation-heavy
+- browser-specific concerns stay out of generic shared navigation design
+
+Flag as a concern when:
+- web URL policy is implicit
+- browser-history binding is mixed into unrelated shared UI code
+- generated URLs are treated as stable public contracts without review
+
+### 11. Adaptive navigation UI
+
+Navigation chrome should adapt to available space.
+
+Check whether:
+- bottom navigation, rail, drawer, or pane structures are chosen intentionally based on window size
+- route state remains stable while shell chrome changes
+- adaptive shell behavior does not duplicate destination logic
+
+Flag as a concern when:
+- phone navigation chrome is forced unchanged onto large layouts
+- shell adaptation and route state are tightly tangled
+- large-screen navigation is bolted on with special cases
+
+### 12. Navigation host architecture
+
+Check whether the navigation host and app shell are decomposed clearly.
+
+Prefer:
+- a navigation host or root shell responsible for app-level structure
+- destination composables that focus on their own UI/state concerns
+- shell chrome separated from destination implementation where practical
+
+Flag as a concern when:
+- one root composable owns all destinations, all shell UI, and all navigation logic inline
+- host code becomes a monolith
+- destination registration and shell layout are tightly tangled
+
+### 13. Animated transitions
+
+Android’s Navigation docs support configuring transitions between destinations, including enter, exit, pop-enter, and pop-exit transitions.
+
+Check whether:
+- transitions support the navigation model instead of obscuring it
+- pop transitions reflect back-stack behavior coherently
+- animation choices do not fight shared-element or other transition systems
+
+Flag as a concern when:
+- transition policy is inconsistent with navigation semantics
+- transitions are added everywhere without improving UX clarity
+- conflicting transition systems are mixed carelessly
+
+### 14. Destination isolation
+
+Check whether each destination remains a meaningful UI boundary.
+
+Prefer:
+- destination UI that owns its local rendering/state concerns
+- dependencies passed through clear contracts
+- destination screens that do not know excessive app-shell detail
+
+Flag as a concern when:
+- destinations depend directly on root-shell internals
+- screens coordinate each other directly instead of going through navigation state
+- navigation concerns dominate destination implementation
+
+### 15. Testability
+
+Navigation architecture should support validation without relying only on full app runs.
+
+Check whether:
+- route selection logic can be tested
+- navigation state transitions can be tested
+- back-stack shaping rules can be validated
+- deep-link matching behavior can be tested separately
+- destination UI can be previewed or tested in isolation
+
+Flag as a concern when:
+- navigation correctness can only be verified through manual app flows
+- route transitions are deeply coupled to platform bootstrapping
+- deep-link ambiguity is only discovered at runtime
+
+---
+
+## Severity framework
+
+### High severity
+Likely to cause broken navigation behavior or architectural drift.
+
+Examples:
+- no clear `NavController` owner
+- `navigate()` called from composable rendering paths
+- complex objects passed through navigation
+- overlapping deep-link patterns
+- uncontrolled back-stack mutation from many places
+
+### Medium severity
+Workable, but likely to create maintenance cost.
+
+Examples:
+- route modeling is too stringly typed
+- shell and destination responsibilities are too tangled
+- back-stack options are used inconsistently
+- browser URL mapping is unclear
+- adaptive navigation exists but is patchy
+
+### Low severity
+Structurally acceptable but worth improving.
+
+Examples:
+- route naming could be clearer
+- transition policy could be more coherent
+- shell composables could be split more cleanly
+
+---
+
+## Required output format
+
+When performing the review, respond with:
+
+1. **Navigation summary**
+   - route model
+   - `NavController` / back-stack ownership
+   - argument strategy
+   - stack-shaping policy
+   - deep-link strategy
+   - adaptive shell approach
+   - platform/web boundary
+
+2. **What is structurally sound**
+   - concrete strengths only
+
+3. **Issues by review dimension**
+   - route modeling
+   - `NavController` ownership
+   - back stack ownership
+   - basic navigation policy
+   - nav options / stack shaping
+   - argument passing
+   - conditional navigation
+   - state hoisting / one-off effects
+   - deep links
+   - multiplatform routing / browser binding
+   - adaptive navigation
+   - navigation host architecture
+   - animated transitions
+   - destination isolation
+   - testability
+
+4. **Severity for each issue**
+   - high / medium / low
+
+5. **Concrete recommendations**
+   - exact restructuring steps
+   - what state should be hoisted
+   - what should stop receiving `NavController`
+   - where to use `launchSingleTop` / `popUpTo` / `saveState`
+   - how to reduce argument payloads
+   - how to clarify deep-link and web-routing policy
+
+6. **Suggested target structure**
+   - proposed route model / shell / destination / deep-link split if useful
+
+7. **Open risks**
+   - migration cost
+   - rollout concerns
+   - platform-specific constraints still to validate
+
+---
+
+## Tone
+
+Be direct and practical.
+Do not praise navigation just because it works on one device or one happy path.
+If the design is weak, say why clearly.
+
+---
+
+## Anti-patterns to flag aggressively
+
+- passing `NavController` deep into composables
+- calling `navigate()` during composition
+- stringly typed routes scattered across the codebase
+- complex objects passed through navigation
+- uncontrolled duplicate destinations on top of the stack
+- auth/onboarding guards duplicated across many screens
+- overlapping deep-link patterns
+- oversized root navigation shells
+- browser-routing policy leaking into generic shared navigation code
+
+---
+
+## References
+
+- Android: Navigate to a destination: https://developer.android.com/guide/navigation/use-graph/navigate
+- Android: Navigate with options: https://developer.android.com/guide/navigation/use-graph/navoptions
+- Android: Pass data between destinations: https://developer.android.com/guide/navigation/use-graph/pass-data
+- Android: Animate transitions between destinations: https://developer.android.com/guide/navigation/use-graph/animate-transitions
+- Android: Conditional navigation: https://developer.android.com/guide/navigation/use-graph/conditional
+- Android: Navigation and the back stack: https://developer.android.com/guide/navigation/backstack
+- Compose Multiplatform: Navigation and routing: https://kotlinlang.org/docs/multiplatform/compose-navigation-routing.html
+- Compose Multiplatform: Deep links: https://kotlinlang.org/docs/multiplatform/compose-navigation-deep-links.html
+- Navigation 3 (alpha — verify status before use): https://developer.android.com/guide/navigation/navigation3

+ 341 - 0
.claude/skills/kotlin-platform-app-links-and-deep-links/SKILL.md

@@ -0,0 +1,341 @@
+---
+name: kotlin-platform-app-links-and-deep-links
+description: Use when designing, implementing, or reviewing Android deep links, web links, and App Links in KMP projects — intent-filter design, host verification, manifest scope, and assetlinks.json configuration.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "1.1.0"
+---
+
+# Android App Links and Deep Links
+
+Use this skill when designing, implementing, or reviewing deep-linking behavior in a Kotlin Multiplatform project with Android support.
+
+This skill is intentionally strict. Its purpose is to keep Android deep links, web links, and App Links structurally correct, verified where appropriate, compatible with realistic navigation behavior, and separated cleanly from shared route interpretation logic.
+
+## Primary goals
+
+The deep-linking design should optimize for:
+
+- clear distinction between deep links, web links, and verified App Links
+- manifest declarations that match intended URL ownership
+- host verification that is reliable and maintainable
+- realistic navigation and back-stack behavior after external entry
+- separation between Android registration/verification and shared route handling
+- minimal ambiguity about which URLs should open the app
+- support for server-side refinement where Dynamic App Links are used
+
+Do not treat all incoming URLs as equivalent.
+Treat verified App Links as a distinct architectural surface.
+
+---
+
+## Official defaults to prefer
+
+Unless the project has a strong reason not to, prefer:
+
+- Android App Links for owned HTTPS web domains
+- custom URI schemes only when web-link verification is not the right tool
+- explicit intent filters with `VIEW`, `DEFAULT`, and `BROWSABLE`
+- separate intent filters for unique URL combinations instead of relying on merged `<data>` semantics
+- broad manifest host scope with finer path-level refinement in `assetlinks.json` when using Dynamic App Links on Android 15+
+- Android registration and verification in platform code
+- shared route interpretation in shared/navigation code
+
+---
+
+## Link-type distinctions
+
+### 1. Custom deep links
+Examples:
+- `myapp://product/123`
+
+Use when:
+- links come from controlled sources
+- you need non-web routing
+- website ownership/verification is not the main requirement
+
+Risks:
+- not standard web links
+- can still trigger chooser/disambiguation issues if other apps claim the same custom scheme
+
+### 2. Web links
+Examples:
+- `https://example.com/product/123`
+
+These are standard web URLs. Without App Link verification, Android may still show disambiguation or route to the browser depending on app/user/system state.
+
+### 3. Android App Links
+These are verified HTTP(S) web links associated with your website. They provide the strongest user-trust and ownership model for opening app content from your web domains.
+
+Review expectation:
+- use App Links for domains the app genuinely owns and wants to claim as app entry points
+
+---
+
+## Review dimensions
+
+### 1. URL ownership and link-type choice
+
+Check whether the proposal uses the right link type for the job.
+
+Prefer:
+- App Links for owned website domains
+- web URLs where website fallback matters
+- custom schemes only when they are truly appropriate
+
+Flag as a concern when:
+- custom schemes are used where verified HTTPS ownership would be better
+- App Links are declared for domains the team does not fully control
+- multiple unrelated link types are mixed with no clear reason
+
+### 2. Intent-filter correctness
+
+For Android deep links and App Links, review whether the manifest filters are structurally correct.
+
+Expected pieces:
+- `android.intent.action.VIEW`
+- `android.intent.category.DEFAULT`
+- `android.intent.category.BROWSABLE`
+
+Check whether:
+- filters declare the intended scheme, host, and optional path scope
+- filters are as clear as possible
+- each filter corresponds to a coherent URL family
+
+Important review rule:
+- do not casually combine unrelated `<data>` elements in one filter, because Android merges them and may create unintended URL combinations
+
+Flag as a concern when:
+- categories or action are incomplete
+- one filter accidentally matches more URLs than intended
+- path scoping is implicit and hard to reason about
+- filter merging creates accidental combinations
+
+### 3. App Link verification model
+
+Review whether host verification is designed correctly.
+
+Check whether:
+- host verification is expected and supported for every declared App Link host
+- the team understands that verification behavior differs by Android version
+- multi-host declarations are intentional
+
+Important platform behavior:
+- on Android 12+, if multiple hosts are declared, the system attempts to verify each one independently, and any verified host can become the default handler for that host
+- on Android 11 and lower, verification can fail for the whole set if one declared host cannot be verified
+
+Flag as a concern when:
+- many hosts are bundled casually into one design without operational ownership
+- verification assumptions ignore Android-version differences
+- a failed host can silently undermine the intended UX
+
+### 4. Manifest scope strategy
+
+Review whether static manifest rules are scoped well.
+
+For Dynamic App Links, Google recommends:
+- broad manifest scope, such as scheme + domain only
+- finer refinement on the server side in `assetlinks.json`
+
+Check whether:
+- static manifest rules are broad enough to support future refinement
+- static rules are not overly narrow if server-driven path changes are expected
+- static rules still avoid claiming unrelated domains or schemes
+
+Flag as a concern when:
+- manifest rules are too narrow for expected evolution
+- manifest rules are too broad for domains the app should not own
+- the manifest tries to encode all path logic when server-side refinement is intended
+
+### 5. assetlinks.json correctness
+
+Review website association files carefully.
+
+Check whether:
+- each supported host serves its own `/.well-known/assetlinks.json`
+- the file is served over HTTPS
+- the content type is `application/json`
+- the file is accessible without redirects
+- the JSON associates the correct package name and certificate fingerprints
+- the relation includes `delegate_permission/common.handle_all_urls` where full App Link handling is intended
+
+Flag as a concern when:
+- the file is missing from one host
+- redirects are relied upon
+- fingerprints are wrong or incomplete
+- one host is configured while sibling hosts are forgotten
+
+### 6. Dynamic App Links (Android 15+ only)
+
+**This is a platform-version-gated feature.** Dynamic App Links are available only on Android 15+ (API 35+) and are not relevant for projects targeting lower minimum API levels. Treat this guidance as conditional on the project's min SDK target.
+
+Dynamic App Links add server-side refinement on top of App Links.
+
+Check whether:
+- the project actually needs dynamic rules
+- dynamic rules refine manifest-declared scope rather than trying to expand it
+- path, fragment, and query matching are used intentionally
+- exclusions are used carefully where some matching URLs should not open the app
+
+Important review rule:
+- dynamic rules cannot expand beyond the hosts and broad URL scope already declared in the manifest
+
+Flag as a concern when:
+- the design assumes server-side rules can claim new undeclared hosts
+- dynamic rules and static rules contradict each other
+- dynamic exclusions create confusing entry behavior
+
+### 7. Shared-vs-platform boundary
+
+Keep Android registration and verification concerns platform-specific.
+
+Prefer:
+- Android manifest, verification behavior, and `assetlinks.json` ownership handled at the Android/platform layer
+- shared code only interpreting the incoming route and deciding in-app destination behavior
+
+For non-Android KMP targets (web, desktop), deep-link handling is a different concern: Compose Multiplatform web targets can bind navigation state to browser URLs via the navigation library's web-target APIs. See the `kotlin-navigation-compose-multiplatform` skill for shared route and web-URL binding guidance. Android-specific App Link verification does not apply to those targets.
+
+Flag as a concern when:
+- shared business/navigation code owns Android verification concerns
+- Android-specific registration details leak into `commonMain`
+- route parsing is duplicated because platform and shared ownership are unclear
+- Android App Link concepts are incorrectly applied to web or desktop KMP targets
+
+### 8. Back-stack and navigation behavior
+
+Deep links should take users directly to relevant content, and navigation after entry should still feel coherent.
+
+Check whether:
+- incoming links open the intended content directly
+- the app avoids unnecessary interstitials before showing linked content
+- back behavior is consistent with how users entered
+- deep-link entry does not bypass critical architecture boundaries
+
+Flag as a concern when:
+- deep links land on generic home screens without reason
+- users hit confusing back-stack behavior after external entry
+- deep-link handling bypasses the normal state-holder/navigation pipeline
+
+### 9. Testing and diagnostics
+
+Check whether deep links and App Links can be validated operationally.
+
+Prefer:
+- adb-based intent testing for deep-link URIs
+- explicit host-by-host verification checks
+- tests or QA flows covering both app-installed and app-not-installed cases
+- review of assetlinks hosting behavior in real environments
+
+Flag as a concern when:
+- correctness depends on manual hope rather than reproducible checks
+- only custom-scheme tests exist while App Links are unverified
+- host verification is assumed but never validated
+
+---
+
+## Severity framework
+
+### High severity
+Likely to cause broken ownership or broken user routing.
+
+Examples:
+- incorrect intent-filter structure
+- accidental overmatching due to merged `<data>` elements
+- missing or invalid `assetlinks.json`
+- manifest scope and server-side rules contradicting each other
+- Android-specific verification logic leaking into shared code
+
+### Medium severity
+Workable, but likely to create maintenance cost or inconsistent UX.
+
+Examples:
+- multi-host setup with weak operational ownership
+- route interpretation split unclearly across layers
+- deep-link back-stack behavior is plausible but confusing
+- dynamic rules are used without a clear refinement strategy
+
+### Low severity
+Structurally acceptable but worth improving.
+
+Examples:
+- route naming could be clearer
+- manifest scope is slightly too specific
+- diagnostics/testing guidance is incomplete
+
+---
+
+## Required output format
+
+When performing the review, respond with:
+
+1. **Deep-linking summary**
+   - link types in use
+   - manifest scope
+   - host verification model
+   - assetlinks strategy
+   - shared/platform boundary
+   - navigation/back-stack behavior
+
+2. **What is structurally sound**
+   - concrete strengths only
+
+3. **Issues by review dimension**
+   - link-type choice
+   - intent-filter correctness
+   - verification model
+   - manifest scope
+   - assetlinks.json correctness
+   - dynamic rules
+   - shared/platform boundary
+   - back-stack/navigation behavior
+   - testing/diagnostics
+
+4. **Severity for each issue**
+   - high / medium / low
+
+5. **Concrete recommendations**
+   - exact manifest changes
+   - host verification fixes
+   - assetlinks changes
+   - route-handling boundary fixes
+   - navigation-entry fixes
+
+6. **Suggested target structure**
+   - proposed manifest / Android-entry / shared-route split if useful
+
+7. **Open risks**
+   - rollout risks
+   - host-verification risks
+   - Android-version behavior differences still to validate
+
+---
+
+## Tone
+
+Be direct and practical.
+Do not assume “link opens app” means the setup is correct.
+If verification, scope, or routing is weak, say so clearly.
+
+---
+
+## Anti-patterns to flag aggressively
+
+- using custom schemes where verified HTTPS App Links should be used
+- multiple unrelated `<data>` declarations merged into one filter accidentally
+- claiming domains the app does not operationally control
+- assuming assetlinks dynamic rules can expand manifest-declared scope
+- missing `assetlinks.json` on one of several supported hosts
+- redirects on `/.well-known/assetlinks.json`
+- Android verification concerns embedded in shared code
+- deep-link entry that bypasses the normal navigation/state pipeline
+
+---
+
+## References
+
+- Android Developers: About deep links — https://developer.android.com/training/app-links/about
+- Android Developers: Create deep links to app content — https://developer.android.com/training/app-links/create-deeplinks
+- Android Developers: Add Android App Links — https://developer.android.com/training/app-links/add-applinks
+- Android Developers: Configure website associations and Dynamic App Links — https://developer.android.com/training/app-links/configure-assetlinks
+- Android Developers: Verify Android App Links — https://developer.android.com/training/app-links/verify-site-associations

+ 346 - 0
.claude/skills/kotlin-platform-kmp-bridges/SKILL.md

@@ -0,0 +1,346 @@
+---
+name: kotlin-platform-kmp-bridges
+description: Use when designing, implementing, or reviewing platform-specific integrations in KMP projects, including source-set placement, hierarchical sharing, expect/actual usage, platform API access, and shared-to-native abstraction boundaries.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "1.2.0"
+---
+
+# Kotlin Multiplatform Platform Bridges
+
+Use this skill when designing, implementing, or reviewing platform-specific integrations and shared-to-native boundaries in a Kotlin Multiplatform project.
+
+This skill is intentionally strict. Its purpose is to keep platform-specific code at the edges, maximize valid sharing through source-set hierarchy, avoid unnecessary `expect`/`actual`, and preserve testable abstractions between shared and native code.
+
+## Primary goals
+
+The bridge design should optimize for:
+
+- correct source-set placement
+- maximum valid code sharing before introducing platform splits
+- clear shared-to-native abstraction boundaries
+- minimal and justified `expect`/`actual` usage
+- platform API access through the least-coupled mechanism
+- reuse across similar platforms through intermediate source sets
+- testability and replaceability of native integrations
+- avoidance of platform/vendor types leaking into shared business logic
+
+Do not default to `expect`/`actual` for every native dependency.
+Start from the least specialized mechanism that solves the problem cleanly.
+
+---
+
+## Official defaults to prefer
+
+Unless the project has a strong reason not to, prefer these defaults:
+
+- share code in `commonMain` when it is valid for all declared targets
+- share code among similar platforms through hierarchical source sets before duplicating implementations
+- use the default hierarchy template unless the project truly requires custom hierarchy wiring
+- check for existing multiplatform libraries before writing platform bridges
+- prefer regular interfaces and injected implementations when they model the dependency well
+- use `expect`/`actual` mainly for narrow platform-specific access points
+- keep `actual` implementations in intermediate source sets like `iosMain` when one implementation is valid for several platform targets
+- keep platform-specific registration, lifecycle, packaging, and SDK wiring outside shared business logic
+
+---
+
+## Review dimensions
+
+### 1. Sharing-first source-set placement
+
+Check whether code is placed in the highest valid shared source set before splitting into platform code.
+
+Prefer:
+- `commonMain` for logic valid for all targets
+- intermediate shared source sets for code valid for some but not all targets
+- platform source sets only when APIs or behavior genuinely differ
+
+Flag as a concern when:
+- code is duplicated in platform source sets even though it could live in shared code
+- platform splitting happens too early
+- `commonMain` is underused because the design assumes platform differences before validating them
+
+### 2. Intermediate source-set usage
+
+Kotlin explicitly supports sharing code among similar targets through hierarchical project structure and intermediate source sets. Examples include `iosMain` for several iOS targets.  (https://kotlinlang.org/docs/multiplatform/multiplatform-share-on-platforms.html)(https://kotlinlang.org/docs/multiplatform/multiplatform-share-on-platforms.html)
+
+Check whether:
+- similar targets reuse logic through intermediate source sets
+- intermediate source sets are used for platform-family APIs and dependencies where appropriate
+- platform-family bridges are not duplicated unnecessarily across concrete targets
+
+Flag as a concern when:
+- `iosX64Main`, `iosArm64Main`, and `iosSimulatorArm64Main` duplicate the same bridge code
+- one platform family could share an `actual` implementation but does not
+- intermediate source sets are ignored without reason
+
+### 3. Default hierarchy template vs manual hierarchy
+
+The hierarchy docs recommend the default hierarchy template for most projects and warn that explicit `dependsOn()` edges cancel it unless you deliberately reapply or opt out.  (https://kotlinlang.org/docs/multiplatform/multiplatform-hierarchy.html)(https://kotlinlang.org/docs/multiplatform/multiplatform-hierarchy.html)
+
+Check whether:
+- the project uses the default hierarchy template when it fits
+- manual hierarchy configuration is justified
+- additional source sets are introduced only when the default template is insufficient
+- hierarchy changes preserve clarity rather than creating hidden build complexity
+
+Flag as a concern when:
+- manual `dependsOn()` graphs exist for cases already covered by the default template
+- the hierarchy is custom but offers no clear architectural benefit
+- source-set topology is difficult to reason about
+
+### 4. Platform-library-before-bridge rule
+
+Before introducing custom bridge code, check whether an existing multiplatform library or platform library already solves the problem.
+
+Kotlin explicitly recommends first checking for multiplatform libraries, and notes that Kotlin/Native ships platform libraries such as Foundation, UIKit, and POSIX that are available to native shared source sets.  (https://kotlinlang.org/docs/multiplatform/multiplatform-connect-to-apis.html)
+
+Check whether:
+- an existing multiplatform library should be preferred
+- native platform libraries are being used directly where appropriate
+- custom bridge code exists only where it adds value
+
+Flag as a concern when:
+- a bespoke bridge is created for a capability already well-covered by a multiplatform dependency
+- platform wrappers duplicate standard library or platform-library behavior without benefit
+
+### 5. Choosing between interfaces, entry points, and expect/actual
+
+Kotlin’s platform-API guidance supports several approaches:
+- interfaces in common code with platform implementations
+- supplying platform implementations from different platform entry points
+- `expect`/`actual` functions/properties
+- DI frameworks for larger architectures.  (https://kotlinlang.org/docs/multiplatform/multiplatform-connect-to-apis.html)
+
+Review whether the chosen mechanism matches the problem size.
+
+Prefer:
+- interfaces in common code when the dependency is substantial or benefits from multiple implementations and easier testing
+- platform entry-point construction when you control startup/bootstrapping cleanly
+- narrow `expect`/`actual` functions or properties for small platform-specific access points
+
+Flag as a concern when:
+- `expect`/`actual` is used for complex object graphs that would be cleaner as interfaces
+- platform implementations are hard-wired deep in shared code when entry-point wiring would suffice
+- the bridge style makes testing or replacement harder than necessary
+
+### 6. expect/actual correctness
+
+If `expect`/`actual` is used, review it strictly.
+
+Kotlin requires:
+- the `expect` declaration in common code
+- matching `actual` declarations for all relevant targets
+- the same package for `expect` and `actual`
+- no implementation in the `expect` declaration.  (https://kotlinlang.org/docs/multiplatform/multiplatform-expect-actual.html)(https://kotlinlang.org/docs/multiplatform/multiplatform-expect-actual.html)
+
+Check whether:
+- signatures match correctly
+- package names match
+- every required target has an `actual`
+- `actual` implementations are placed at the right hierarchy level
+
+Flag as a concern when:
+- `expect` declarations include implementation
+- packages differ
+- one target is missing an `actual`
+- `actual` code is duplicated in concrete targets when an intermediate source set would suffice
+
+### 7. Avoid overusing expect/actual classes
+
+Kotlin explicitly recommends relying on standard language constructs wherever possible. The `expect`/`actual` mechanism overall is stable, but `expect`/`actual` *classes* (non-annotation `expect` class declarations) carry restrictions: in Kotlin 2.0+, non-annotation `expect` classes must have a corresponding `actual` class (not a typealias) in each target, and their member declarations must match. This makes `expect`/`actual` classes heavier to maintain than `expect` functions or properties. Always check the current Kotlin docs for the latest stability status of this feature.  (https://kotlinlang.org/docs/multiplatform/multiplatform-expect-actual.html)(https://kotlinlang.org/docs/multiplatform/multiplatform-expect-actual.html)
+
+Check whether:
+- interfaces, functions, properties, or factories would be enough
+- `expect`/`actual` classes are used only when truly justified
+- the team understands the tradeoff of choosing a Beta language feature
+
+Flag as a concern when:
+- simple abstractions are modeled as `expect` classes without need
+- the design unnecessarily restricts each target to one implementation
+- fakes and alternative implementations become harder because of class-based actualization
+
+### 8. Shared contract quality
+
+Shared code should define what the app needs, not how each platform implements it.
+
+Check whether:
+- shared contracts are small and purposeful
+- common abstractions hide platform/vendor details
+- bridge surfaces are stable enough for callers
+- shared contracts model capabilities, not SDK quirks
+
+Flag as a concern when:
+- shared contracts expose Android/iOS/vendor terminology unnecessarily
+- common code depends on native SDK shapes
+- platform concerns drive domain model design
+
+### 9. Entry-point wiring
+
+Kotlin documents passing platform implementations from platform entry points as a valid alternative to `expect`/`actual`. In practice, this means: at the platform main function or app startup (e.g. `MainActivity.onCreate()` on Android, the app entry point on iOS), the platform constructs concrete implementations of shared interfaces and passes them into shared code — typically via constructor injection, factory functions, or a DI container. Shared code receives an already-constructed dependency and does not need to know which platform provided it. (https://kotlinlang.org/docs/multiplatform/multiplatform-connect-to-apis.html)
+
+**What "entry-point wiring" means concretely:** the platform's main entry point (e.g., Android `Application.onCreate()`, iOS `main.kt`, desktop `main()`) constructs platform-specific implementations and injects them into shared code — typically through a constructor, factory function, or DI graph — rather than having shared code create or locate them itself. Shared code depends on an interface or abstract type; only the entry point knows the concrete class.
+
+Check whether:
+- platform-specific instantiation happens at platform entry points when appropriate
+- shared code receives already-constructed dependencies instead of owning platform bootstrapping
+- platform startup remains thin and explicit
+- different platforms can supply different implementations of the same interface without touching shared code
+
+Flag as a concern when:
+- shared modules instantiate platform-specific implementations implicitly
+- entry-point wiring is duplicated across many places
+- platform lifecycle/setup concerns leak into common business logic
+
+### 10. Native/vendor leakage
+
+Check whether platform-specific types stay behind the bridge.
+
+Prefer:
+- SDK wrappers that convert callbacks and data into project-owned types
+- shared modules depending on app-owned interfaces and models
+
+Flag as a concern when:
+- shared code imports vendor or platform SDK types
+- platform callback shapes leak through common layers
+- business logic becomes tied to native SDK behavior
+
+### 11. Platform-family API access
+
+The hierarchy docs note that intermediate source sets can access APIs available for the targets they compile to, and Kotlin/Native platform libraries can be used from such shared native source sets.  (https://kotlinlang.org/docs/multiplatform/multiplatform-hierarchy.html)(https://kotlinlang.org/docs/multiplatform/multiplatform-hierarchy.html)
+
+Check whether:
+- iOS-family code uses `iosMain` or another appropriate intermediate source set when one implementation covers the whole family
+- target-subset sharing is used intentionally for API families such as Apple/native groupings
+- the source-set choice matches actual API availability
+
+Flag as a concern when:
+- code is pushed down to per-target source sets unnecessarily
+- intermediate source sets are used carelessly without validating API availability across their targets
+
+### 12. Testability and replaceability
+
+Check whether:
+- common code can be tested against interfaces or narrow factories
+- fake implementations are easy to provide
+- bridge decisions do not force tests to run through full platform bootstrapping
+- one platform can support multiple implementations when useful
+
+Flag as a concern when:
+- `expect`/`actual` choices make fakes harder than necessary
+- bridge logic can only be validated in end-to-end platform runs
+- common code is tightly coupled to one concrete platform implementation style
+
+---
+
+## Severity framework
+
+### High severity
+Likely to cause architectural drift or invalid source-set/platform coupling.
+
+Examples:
+- platform-specific APIs in `commonMain`
+- missing `actual` implementations
+- mismatched packages between `expect` and `actual`
+- large feature logic embedded in platform bridges
+- unnecessary per-target duplication instead of intermediate source sets
+
+### Medium severity
+Workable, but likely to create maintenance cost.
+
+Examples:
+- overuse of `expect`/`actual` where interfaces would be cleaner
+- manual hierarchy configuration without strong benefit
+- weak entry-point wiring boundaries
+- native/vendor terminology leaking into shared contracts
+
+### Low severity
+Structurally acceptable but worth improving.
+
+Examples:
+- bridge naming obscures capability ownership
+- an intermediate source set could be introduced later
+- factories could be simplified
+
+---
+
+## Required output format
+
+When performing the review, respond with:
+
+1. **Bridge summary**
+   - shared contracts
+   - source-set placement
+   - hierarchy usage
+   - expect/actual usage
+   - entry-point wiring
+   - native integration boundaries
+
+2. **What is structurally sound**
+   - concrete strengths only
+
+3. **Issues by review dimension**
+   - sharing-first placement
+   - intermediate source sets
+   - hierarchy-template usage
+   - library-before-bridge choice
+   - interface vs entry-point vs expect/actual choice
+   - expect/actual correctness
+   - expect/actual class overuse
+   - shared contract quality
+   - entry-point wiring
+   - native/vendor leakage
+   - platform-family API access
+   - testability
+
+4. **Severity for each issue**
+   - high / medium / low
+
+5. **Concrete recommendations**
+   - exact source-set moves
+   - hierarchy simplifications
+   - where to replace expect/actual with interfaces or factories
+   - where to move actual implementations to intermediate source sets
+   - where to push platform construction to entry points
+
+6. **Suggested target structure**
+   - proposed common / intermediate / platform split if useful
+
+7. **Open risks**
+   - migration cost
+   - hierarchy/build impact
+   - platform-specific constraints still to validate
+
+---
+
+## Tone
+
+Be direct and practical.
+Do not praise a bridge just because it works on one platform.
+If the bridge design is weak, say why clearly.
+
+---
+
+## Anti-patterns to flag aggressively
+
+- platform-specific APIs in `commonMain`
+- unnecessary duplication across similar platform targets
+- manual source-set hierarchy where the default template would be enough
+- using expect/actual classes when interfaces or factories would suffice
+- missing or mismatched actual declarations
+- platform/vendor types leaking into shared business logic
+- shared contracts shaped around SDK quirks
+- platform bootstrapping hidden inside common code
+- bridge choices that make testing unnecessarily hard
+
+---
+
+## References
+
+- Kotlin Multiplatform: Share code on platforms: https://kotlinlang.org/docs/multiplatform/multiplatform-share-on-platforms.html
+- Kotlin Multiplatform: Expected and actual declarations: https://kotlinlang.org/docs/multiplatform/multiplatform-expect-actual.html
+- Kotlin Multiplatform: Use platform-specific APIs: https://kotlinlang.org/docs/multiplatform/multiplatform-connect-to-apis.html
+- Kotlin Multiplatform: Hierarchical project structure: https://kotlinlang.org/docs/multiplatform/multiplatform-hierarchy.html
+- Kotlin releases and stability notes: https://kotlinlang.org/docs/releases.html

+ 751 - 0
.claude/skills/kotlin-project-architecture-review/SKILL.md

@@ -0,0 +1,751 @@
+---
+name: kotlin-project-architecture-review
+description: Use when reviewing KMP architecture, feature proposals, PR structure, layer boundaries, state-holder design, Android entry-point discipline, source-set placement, modularization, and long-term maintainability in Kotlin Multiplatform / Compose Multiplatform projects.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "4.1.0"
+---
+
+# Kotlin Multiplatform Architecture Review
+
+Use this skill to review architecture decisions, feature plans, pull requests, migrations, and refactors in a Kotlin Multiplatform project.
+
+This skill is **architecture-review only**. It evaluates structural fit, ownership, boundaries, layering, source-set placement, Android entry-point discipline, resilience, security/privacy boundaries, rollout safety, and long-term maintainability.
+
+It does **not** perform detailed implementation-level code review for Compose recomposition, coroutine misuse, static-analysis details, line-by-line refactoring, or file-level style issues. For that, use `kotlin-project-code-review`.
+
+This skill is intentionally strict. Its purpose is to protect maintainability, correctness of shared code placement, clean boundaries between layers, realistic ownership of state and data, Android entry-point discipline, safe trust boundaries, diagnosability, rollout resilience, and long-term scalability.
+
+---
+
+## Primary review goals
+
+The review should validate whether the proposal:
+
+- preserves a clear single source of truth for important data
+- follows unidirectional data flow
+- uses a proper UI state production pipeline
+- keeps Android components as platform entry points rather than business-logic containers
+- places business logic in the right layer
+- uses the domain layer only when it adds real value
+- keeps repositories and data sources responsible for data ownership concerns
+- uses source sets correctly in KMP
+- preserves modular boundaries and avoids accidental coupling
+- keeps platform-specific behavior at the edges
+- remains testable and understandable as the codebase grows
+- is resilient to partial backend data, evolving schemas, and phased rollouts
+- does not introduce architectural security or privacy weaknesses
+- preserves diagnosability for important flows
+- does not over-couple features, modules, or targets in ways that will slow future evolution
+
+Do not optimize for theoretical purity alone.
+
+Optimize for:
+- maintainability
+- correctness
+- consistency
+- architectural clarity
+- safe evolution
+- production resilience
+
+---
+
+## Official architecture defaults to review against
+
+Unless the project has a strong, deliberate reason not to, prefer these defaults:
+
+- Single source of truth for each important data type
+- Unidirectional data flow
+- UI driven from data models
+- State holders for UI complexity
+- ViewModels or equivalent state holders such as presenters, reducers, or state machines exposing UI state and receiving user actions
+- Coroutines and Flow for async work and observable state
+- Clear separation of UI, domain, data, and platform integration
+- Domain layer only when business logic is complex or reused
+- Repositories as the main boundary for exposing and coordinating app data
+- Android components treated as lifecycle-bound entry points, not as general-purpose business-logic containers
+- Platform-specific behavior isolated at the edges
+- Transport, persistence, and external SDK details hidden behind stable boundaries
+- Defensive handling of partial data, unknown values, and rollout skew
+- Observability designed into high-risk flows
+- Minimal, explicit trust boundaries for auth/session/admin or privileged behavior
+
+---
+
+## What this skill should review
+
+Use this skill for:
+- feature architecture proposals
+- source-set and shared-vs-platform placement decisions
+- module extraction or consolidation decisions
+- state-holder strategy decisions
+- navigation architecture changes
+- deep link / intent / manifest surface changes
+- SSOT ownership decisions
+- repository/domain boundary design
+- Android entry-point ownership and delegation
+- cross-feature coordination and modularization
+- persistence/source-of-truth architecture
+- architecture-level security/privacy and rollout concerns
+- PRs that introduce structural changes rather than only local implementation changes
+
+Use `kotlin-project-code-review` instead when the main question is whether implemented code is clean, safe, performant, and consistent within an already chosen architecture.
+
+---
+
+## Review dimensions
+
+### 1. Single source of truth
+
+Check whether every important piece of data has one clear owner.
+
+Questions:
+- What is the source of truth for this data?
+- Is there more than one writable owner?
+- Is UI holding durable app truth that should live lower?
+- Is repository ownership explicit?
+- In offline-first or cache-backed flows, is local persistence treated as source of truth when appropriate?
+- Is there a coherent ownership strategy across app memory, persistence, network, and UI?
+
+Flag as a concern when:
+- multiple layers mutate the same data independently
+- UI owns business-critical state beyond screen-local concerns
+- network payloads are treated as truth where resilient local ownership is needed
+- ownership is ambiguous
+- memory, persistence, and remote state are merged ad hoc without a clear owner
+- feature-level local caches bypass the intended source of truth
+
+### 2. Unidirectional data flow
+
+Review whether state and events move in one direction.
+
+Expected shape:
+- user events move upward into a state holder
+- state is produced by state holders from data/domain inputs
+- rendered UI consumes state
+- updates come back as new state instead of ad hoc mutation
+
+Check whether:
+- UI renders from state instead of pulling dependencies directly
+- state is not mutated from several unrelated places
+- one-off events are not confused with long-lived UI state
+- data changes propagate through a coherent pipeline
+- state restoration or background refresh still fits the same flow model
+
+Flag as a concern when:
+- composables call repositories directly
+- business logic runs in rendering code
+- state is changed from multiple layers without a clear owner
+- navigation, snackbar, toast, or permission effects are mixed into persistent state without clear modeling
+- side-entry flows bypass the main state pipeline
+
+### 3. Android component entry-point discipline
+
+Android components are entry points with distinct lifecycles, and Activities / Fragments primarily host UI.
+
+Check whether:
+- Activities and Fragments are treated as UI hosts and platform lifecycle boundaries
+- Services are used only for real background/service responsibilities
+- BroadcastReceivers are thin entry points that delegate quickly
+- ContentProviders are used intentionally rather than as general architecture shortcuts
+- platform entry points delegate to state holders, repositories, or other appropriate layers
+- component boundaries still make sense under process death, restart, background delivery, and external invocation
+
+Flag as a concern when:
+- Activities or Fragments own business rules, repository coordination, transport parsing, or data orchestration
+- Services are used as architecture dumping grounds
+- BroadcastReceivers contain meaningful feature orchestration inline
+- platform entry points become the effective source of truth
+- entry-point behavior depends on hidden assumptions rather than explicit delegation
+
+### 4. UI layer responsibilities
+
+The UI layer should consume app data, render it, handle user interactions, and reflect event effects in UI state.
+
+Check whether:
+- UI is focused on rendering and interaction
+- state holders sit between UI and lower layers
+- UI models are shaped for rendering rather than mirroring transport models
+- screen states include loading, success, empty, error, partial-data, and retry when needed
+- presentation-specific formatting and rendering concerns are kept in the presentation edge
+
+Flag as a concern when:
+- UI owns repository or data-source orchestration
+- DTOs reach the UI directly
+- UI performs large business transformations inline
+- UI state is incoherent or under-modeled
+- UI is responsible for interpreting backend semantics that belong lower
+
+### 5. State-holder quality
+
+Review whether state holders are doing the right work.
+
+The specific mechanism is less important than whether the contract is satisfied:
+- one immutable observable state output
+- separate one-time effects
+- user actions as inputs
+- no business logic in the UI layer
+
+Check whether:
+- there is a clear state-holder boundary such as ViewModel, presenter, reducer, or equivalent
+- immutable state is exposed, not mutable state flows accessible from outside
+- the state holder consumes user actions and produces UI state without being a two-way bridge
+- business logic and UI rendering logic are not mixed inline
+- state production is based on clear, traceable inputs and outputs
+- one-time effects are modeled separately from persistent `UiState`
+- async state transitions are structurally understandable
+- similar screens use similar state-holder patterns unless there is a good reason not to
+
+Flag as a concern when:
+- no state holder exists despite meaningful screen complexity
+- the state holder is a god object
+- mutable state is leaked broadly
+- UI state and one-time effects are conflated into the same stream
+- state-holder pattern is inconsistent across similar screens without a stated reason
+- state holders absorb responsibilities that should belong to repositories, use cases, or coordinators
+
+### 6. Domain layer usage
+
+The domain layer is optional. It should exist when it reduces duplication or isolates meaningful business logic.
+
+Check whether:
+- domain use cases encapsulate complex or reusable business logic
+- domain models stay independent of UI and transport concerns
+- the domain layer adds clarity rather than indirection
+- use cases own meaningful decisions rather than forwarding trivially
+
+Flag as a concern when:
+- a domain layer exists but only forwards calls
+- trivial pass-through use cases add ceremony with no isolation benefit
+- domain code depends on framework, UI, or transport details
+- reusable business logic is duplicated across state holders instead of extracted
+- the domain layer is omitted even though meaningful business rules are shared or complex
+
+### 7. Data layer responsibilities
+
+Repositories should expose data, centralize changes, resolve conflicts across sources, abstract sources, and own data-related coordination.
+
+Check whether:
+- repositories expose app/domain-facing outputs
+- repositories centralize writes and coordination
+- multiple data sources are resolved in one place
+- data sources remain implementation details where appropriate
+- the rest of the app is insulated from transport and persistence specifics
+- cache / persistence / refresh behavior has a clear ownership model
+- remote, local, and in-memory coordination is coherent
+
+Flag as a concern when:
+- repositories merely mirror raw endpoints
+- UI or state-holder coordinates local and remote data directly
+- repository ownership is bypassed
+- persistence and network details leak upward
+- refresh / invalidation / reconciliation responsibilities are ambiguous
+- repositories are so generic they stop owning meaningful app data decisions
+
+### 8. Failure model and error-handling architecture
+
+Check whether:
+- the project has a consistent failure model
+- failures are surfaced deliberately
+- repository/data errors are normalized when needed
+- user-facing messages are derived at the presentation edge
+- retryable and non-retryable failures are distinguishable when relevant
+- cancellation, timeout, auth failure, validation failure, and partial-data cases have structurally sensible treatment
+- degraded-state behavior is intentionally modeled
+
+Flag as a concern when:
+- strings are the effective error model
+- each layer invents its own failure contract
+- failures are swallowed silently
+- transport messages are shown directly to users by default
+- failure handling differs arbitrarily across similar features
+- error pathways are only understandable by reading scattered implementation details
+
+### 9. Layering and separation of concerns
+
+Expected layers:
+- presentation / UI
+- orchestration / state-holder
+- optional domain
+- data / repositories / services / persistence
+- platform integration
+
+Check whether:
+- UI contains business rules
+- domain depends on transport, UI, or platform details
+- DTOs leak into domain or presentation
+- repositories own data coordination
+- platform concerns remain outside business logic
+- mapping responsibility is explicit and stable
+
+Flag as a concern when:
+- one file or module mixes UI, networking, mapping, and business rules
+- repository implementations live inside state holders
+- domain models are actually transport models
+- platform SDK types appear in shared business code
+- abstractions exist but do not line up with real ownership
+
+### 10. Dependency boundaries and lifetime design
+
+Check whether:
+- dependency ownership is clear
+- stateful collaborators are scoped appropriately
+- lifetimes align with feature, screen, session, or app ownership
+- construction paths remain testable and explicit
+- long-lived state is not accidentally held by short-lived components or vice versa
+
+Flag as a concern when:
+- major collaborators are instantiated ad hoc in feature code
+- stateful objects are shared too broadly
+- object lifetime is longer than necessary
+- implicit dependency access or service-locator-like patterns obscure ownership
+- dependencies make ownership impossible to trace
+
+### 11. Source-set correctness in KMP
+
+Check whether code in `commonMain` is truly valid for all declared targets.
+
+Review:
+- whether `commonMain` references platform APIs
+- whether target-specific behavior is isolated
+- whether source-set placement follows compilation reality rather than convenience
+- whether the design would still work if more targets were added
+- whether the proposal is over-sharing code that is only common accidentally
+
+Flag as a concern when:
+- platform-specific APIs appear in shared code
+- shared code assumes one platform’s lifecycle, resources, filesystem, or navigation model
+- platform-only dependencies leak into common code
+- code is pushed into `commonMain` only to reduce duplication, despite bad abstraction fit
+
+### 12. Shared vs platform-specific boundary quality
+
+Check whether:
+- the proposal shares the right things
+- native concerns stay at the edges
+- expect/actual is justified and small
+- abstraction boundaries are minimal and clear
+- platform differences remain understandable rather than hidden behind vague interfaces
+
+Flag as a concern when:
+- platform-specific code leaks into business logic
+- large expect/actual surfaces own feature logic
+- native or vendor types spread through shared modules
+- abstractions hide meaningful behavioral differences in a confusing way
+- the proposal creates portability costs without meaningful reuse benefit
+
+### 13. Module boundaries and modularization quality
+
+Check whether:
+- each module has a clear purpose
+- dependencies are intentional and minimal
+- public APIs are narrow
+- shared modules are truly shared and not dumping grounds
+- feature ownership remains cohesive
+- modules align with actual ownership, not only packaging aesthetics
+
+Flag as a concern when:
+- unrelated features depend on each other directly
+- common/shared/core modules accumulate unrelated code
+- visibility is broad for convenience
+- granularity is either too coarse or too fragmented
+- modules exist only to satisfy theory while increasing coupling or indirection
+
+### 14. Navigation and Android component interaction
+
+Check whether:
+- navigation ownership is clear
+- route definitions are coherent
+- start-destination behavior is understandable
+- back behavior is realistic
+- intent-driven entry points fit the navigation model
+- Activities launched via explicit or implicit intents still delegate into the same architecture instead of bypassing it
+- deep-link entry and in-app navigation converge cleanly
+
+Flag as a concern when:
+- deep links or intent entries create architecture bypasses
+- routes are brittle and stringly typed without structure
+- navigation behavior depends on hidden assumptions
+- a feature’s real entry points differ depending on how the user arrived there
+- navigation ownership is split across too many layers
+
+### 15. Manifest and exported-surface review
+
+Android components must be visible to the system through the manifest, and manifest declarations define part of the app’s architectural surface.
+
+Check whether:
+- Activities, Services, and ContentProviders that should run are declared appropriately
+- BroadcastReceivers are declared or dynamically registered intentionally
+- intent filters are added only where they represent real external entry points
+- manifest exposure matches the intended architecture surface
+- privileged or admin-like flows are not overexposed
+- exported components are justified and bounded
+
+Flag as a concern when:
+- components rely on accidental manifest exposure
+- architectural entry points are unclear from declarations
+- too many components are externally reachable without a clear reason
+- manifest declarations and actual ownership boundaries drift apart
+- exported surface is broader than the product actually needs
+
+### 16. Security and privacy architecture
+
+Review the proposal for structural trust-boundary issues.
+
+Check whether:
+- authorization-sensitive behavior is not trusted to UI alone
+- external input boundaries are explicit
+- deep links, WebView, URLs, intents, files, or externally supplied identifiers are handled defensively at an architectural level
+- sensitive data stays in the minimum number of layers
+- privileged/admin flows are isolated appropriately
+- logging/analytics boundaries avoid leaking sensitive values by design
+- session/auth state transitions are owned and bounded clearly
+
+Flag as a concern when:
+- permission checks are only enforced in UI
+- client state is treated as trusted for privileged behavior
+- external inputs can bypass intended ownership boundaries
+- sensitive data spreads through layers that do not need it
+- architecture assumes backend authorization without clear boundary handling
+- post-logout or role-change state ownership is unclear
+- observability design would require logging sensitive data to diagnose problems
+
+### 17. Responsiveness and configuration resilience
+
+Check whether:
+- UI state production is resilient to configuration changes
+- layout adaptation is structured rather than bolted on
+- state holders remain valid across lifecycle/configuration changes where appropriate
+- layout assumptions are not hard-coded to one form factor
+- state restoration and re-entry do not break ownership assumptions
+
+Flag as a concern when:
+- configuration change handling is fragile
+- adaptive layouts require rewriting feature logic
+- state is tied too tightly to one screen shape
+- architecture assumes one device class or one platform behavior
+- restored state and fresh data paths are structurally incompatible
+
+### 18. Resources and presentation boundaries
+
+Check whether:
+- localization and resources stay in presentation/platform concerns where appropriate
+- shared business logic does not hard-code values that belong in resources
+- locale-sensitive formatting happens at the presentation edge, not in repositories or use cases
+- the feature can evolve to alternative resources, configurations, or locales without major refactoring
+
+Flag as a concern when:
+- user-facing strings are assembled or formatted in repositories or use cases
+- locale-sensitive logic runs inside business rules rather than at the presentation edge
+- presentation constants or hard-coded display values are buried in shared data or domain layers
+- the design assumes a single language, locale, density, or configuration
+- presentation details leak into supposedly reusable shared logic
+
+### 19. Observability and diagnosability as architecture
+
+Check whether:
+- important flows have a diagnosable path
+- failures can be surfaced with enough context to debug in production
+- high-risk operations have structural places for logging, analytics, or error capture
+- sensitive information is not required to diagnose common failures
+- ownership of runtime faults is clear enough that teams can reason about failures quickly
+
+Flag as a concern when:
+- critical flows can fail silently
+- diagnostics would require reading UI code paths only
+- feature ownership and runtime failure ownership are unclear
+- important errors have no clear propagation path
+- observability is bolted on in ways that cross too many boundaries
+- there is no architecture-level place to capture or correlate meaningful failure context
+
+### 20. Backward compatibility, migration, and rollout safety
+
+Check whether:
+- the design tolerates partial backend rollout
+- unknown enum values, missing fields, and extra fields are survivable
+- local persistence changes consider migration
+- old and new app versions can coexist reasonably when needed
+- new feature paths degrade safely when unavailable
+- capability mismatches are handled explicitly where relevant
+
+Flag as a concern when:
+- the design assumes all backends and clients upgrade simultaneously
+- persisted models change without migration thought
+- server capabilities are treated as always available
+- unknown values break business flows
+- rollout requires risky all-at-once coupling
+- the architecture gives no clean place to branch for capability differences
+
+### 21. Testability as an architectural property
+
+Check whether:
+- business rules are isolated for unit tests
+- mappers are pure and testable
+- repositories can be tested with fakes/mocks
+- state-holder logic can be tested without rendering UI
+- key failure paths are testable
+- platform entry points are thin enough that most logic is testable outside them
+- architecture supports meaningful deterministic tests rather than only end-to-end coverage
+
+Flag as a concern when:
+- critical logic is trapped in Activities, Services, Receivers, or other platform entry points
+- key flows can only be tested end to end
+- architecture relies on hidden global state
+- ownership is so blurred that tests must duplicate architecture knowledge
+- async coordination is too implicit to test predictably
+
+### 22. Architecture consistency with existing project patterns
+
+Review whether the proposal fits the project’s established architectural direction unless there is a strong reason to depart from it.
+
+Check whether:
+- the proposal extends existing module, state-holder, and repository patterns where reasonable
+- new abstractions are justified instead of introduced for novelty
+- design choices are consistent with surrounding feature architecture
+- the proposal avoids parallel patterns for the same problem
+
+Flag as a concern when:
+- the change introduces a competing architecture style without justification
+- similar features would now require different mental models
+- the proposal solves a local problem by creating long-term inconsistency
+- existing patterns are bypassed without a documented reason
+
+---
+
+## Severity framework
+
+### High severity
+
+Likely to cause architectural drift, correctness problems, security exposure, or rollout risk.
+
+Examples:
+- no single source of truth
+- business logic embedded in Activities or Fragments
+- repositories bypassed by UI/state-holder code
+- platform APIs in `commonMain`
+- major module-boundary violations
+- manifest/exported entry points that bypass intended architecture
+- authorization-sensitive behavior trusted to UI only
+- rollout assumptions that require synchronized upgrades
+- externally reachable entry points that expose privileged flows accidentally
+
+### Medium severity
+
+Workable, but likely to create maintenance cost or fragility.
+
+Examples:
+- weak domain-layer justification
+- oversized state holder
+- DTO leakage into presentation
+- unclear module ownership
+- inconsistent failure modeling
+- partial observability gaps
+- insufficient migration or partial-data resilience
+- portability costs introduced without strong benefit
+- competing patterns appearing in nearby features
+
+### Low severity
+
+Structurally acceptable but worth improving.
+
+Examples:
+- naming obscures ownership
+- package split could be clearer
+- tests miss important transitions
+- route modeling could be more explicit
+- diagnostics could be more deliberate
+- ownership is correct but not obvious enough from the design
+
+---
+
+## Required output format
+
+When performing the review, respond with:
+
+1. **Verdict**
+   - good fit
+   - acceptable with revisions
+   - poor fit
+
+2. **Architecture summary**
+   - what the proposal is doing
+   - which layers, modules, source sets, and Android entry points it affects
+   - whether this is a local structural adjustment or a broader architectural shift
+
+3. **What is structurally sound**
+   - concrete strengths only
+
+4. **Issues by review dimension**
+   - SSOT
+   - UDF
+   - Android component boundaries
+   - UI layer
+   - state-holder quality
+   - domain-layer usage
+   - data-layer design
+   - failure model
+   - dependency/lifetime design
+   - source sets
+   - shared vs platform boundaries
+   - modularization
+   - navigation / intents / manifest surface
+   - security / privacy architecture
+   - responsiveness/resources
+   - observability
+   - backward compatibility / rollout safety
+   - testability
+   - architecture consistency with existing project patterns
+   - other relevant sections
+
+5. **Severity for each issue**
+   - high / medium / low
+
+6. **Concrete recommendations**
+   - exact structural changes
+   - better layer placement
+   - better component delegation
+   - better module/source-set placement
+   - better ownership boundaries
+   - safer rollout / migration / authorization boundaries where needed
+   - whether the proposal should be narrowed to reduce architectural surface area
+
+7. **Suggested target structure**
+   - proposed module/package/source-set / entry-point layout if useful
+   - proposed ownership map if useful
+
+8. **Open risks**
+   - migration cost
+   - rollout concerns
+   - backward-compatibility concerns
+   - operational/debugging concerns
+   - cross-platform consistency concerns
+
+---
+
+## Tone
+
+Be direct and practical.
+
+Do not give vague praise.
+
+If the proposal is weak, say so clearly and explain why.
+
+Do not soften structural criticism with filler. The value of the review comes from precision.
+
+---
+
+## Anti-patterns to flag aggressively
+
+- no clear single source of truth
+- bidirectional or ad hoc state mutation
+- business logic in composables, Activities, or Fragments
+- DTO-driven UI
+- state-holder-free complex screens
+- meaningless pass-through domain layer
+- repositories bypassed by upper layers
+- transport details leaking upward
+- platform-specific APIs in `commonMain`
+- modules with unclear purpose
+- manifest or intent-filter surface that does not match the intended architecture
+- hidden or inconsistent failure handling
+- architecture that is only testable through large integration paths
+- permission checks only in UI
+- untrusted external input bypassing intended architecture boundaries
+- rollout-sensitive changes with brittle assumptions
+- critical flows with no diagnosable ownership path
+- parallel architectural patterns introduced without good reason
+- large shared abstractions that hide important platform behavior
+- source-set decisions made for convenience rather than correctness
+
+---
+
+## Review method
+
+Follow this sequence:
+
+### Step 1: Understand the proposal
+
+Identify:
+- what is changing
+- what architectural problem it is trying to solve
+- which layers/modules/entry points/source sets are affected
+- whether the proposal is local or cross-cutting
+- what new ownership or responsibilities are being introduced
+
+### Step 2: Review structural fit
+
+Evaluate:
+- ownership clarity
+- layering
+- source-of-truth placement
+- state-holder boundaries
+- repository/domain responsibilities
+- platform/shared split
+- Android component delegation
+- navigation and entry-point fit
+- trust boundaries
+- rollout/migration resilience
+
+### Step 3: Review long-term evolution cost
+
+Evaluate:
+- whether the proposal makes future features easier or harder
+- whether it creates a new parallel pattern
+- whether it increases coupling across targets/modules/features
+- whether it is diagnosable in production
+- whether it will be testable without fragile end-to-end dependence
+
+### Step 4: Prefer targeted structural changes
+
+Do not recommend a broad rewrite unless the proposal is fundamentally broken.
+
+Prefer:
+- narrowing responsibility
+- restoring proper ownership
+- moving code or responsibilities to the right boundary
+- reducing exported surface
+- simplifying shared/platform boundaries
+- clarifying module responsibilities
+- making rollout and failure handling more explicit
+
+### Step 5: Summarize with a clear verdict
+
+Use the required output format and be explicit about what is:
+- structurally sound
+- structurally risky
+- fixable locally
+- likely to require broader migration planning
+
+---
+
+## Final instruction
+
+Review like an architect who will have to maintain this system for years.
+
+Be strict about:
+- correctness
+- long-term scalability
+- ownership clarity
+- architectural consistency
+- safe platform/shared boundaries
+- security and privacy boundaries
+- diagnosability
+- rollout safety
+- testability
+
+Do not optimize for politeness.
+
+Optimize for protecting the codebase.
+
+
+## References
+
+- Android app architecture: https://developer.android.com/topic/architecture
+- Android architecture recommendations: https://developer.android.com/topic/architecture/recommendations
+- Android UI layer: https://developer.android.com/topic/architecture/ui-layer
+- Android domain layer: https://developer.android.com/topic/architecture/domain-layer
+- Android data layer: https://developer.android.com/topic/architecture/data-layer
+- Android application fundamentals: https://developer.android.com/guide/components/fundamentals
+- Kotlin Multiplatform project structure: https://kotlinlang.org/docs/multiplatform/multiplatform-discover-project.html
+- Kotlin Multiplatform hierarchy: https://kotlinlang.org/docs/multiplatform/multiplatform-hierarchy.html

+ 399 - 0
.claude/skills/kotlin-project-bugfix/SKILL.md

@@ -0,0 +1,399 @@
+---
+name: kotlin-project-bugfix
+description: Use when diagnosing and fixing bugs in a Kotlin Multiplatform project. Focus on root-cause analysis, minimal safe fixes, KMP correctness, UI/state/data/persistence/concurrency issues, and regression prevention.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "1.0.0"
+---
+
+# Kotlin Project Bug Fix
+
+Use this skill when fixing an existing bug in a Kotlin Multiplatform project.
+
+This is a bug-fixing skill, not a feature-building skill and not a review-only skill.
+
+Your goal is to:
+- identify the real root cause
+- fix the bug with the smallest correct change
+- avoid unrelated refactors
+- preserve the current architecture unless the bug proves the design is wrong
+- prevent regressions with targeted tests where appropriate
+
+Do not optimize for speed alone.
+Optimize for correctness, stability, maintainability, and regression safety.
+
+---
+
+## Core bug-fixing philosophy
+
+Do not patch symptoms before understanding the path that produces them.
+
+Always trace the bug across the full flow that could be involved:
+- UI rendering
+- state holder / ViewModel / presenter
+- domain/business logic
+- repository/data layer
+- persistence/cache
+- mapping between layers
+- navigation/lifecycle/re-entry
+- source-set/platform-specific behavior
+- coroutine/flow/concurrency timing
+
+Prefer the smallest coherent fix that addresses the true cause.
+
+Do not refactor unrelated areas during bug fixing unless the refactor is necessary to safely fix the bug.
+
+---
+
+## Primary goals
+
+For every bug fix, optimize for:
+
+1. **Root-cause correctness**
+2. **Minimal diff**
+3. **Preservation of architecture**
+4. **No unrelated cleanup**
+5. **Regression prevention**
+6. **Clear state/model boundary handling**
+7. **KMP source-set correctness**
+8. **Compose/lifecycle correctness**
+9. **Coroutine/cancellation/concurrency correctness**
+10. **Persistence/re-entry safety**
+11. **Security/privacy safety where relevant**
+12. **Testability**
+
+---
+
+## Required workflow
+
+Follow this workflow unless explicitly told otherwise.
+
+### Step 1: Classify the bug
+First classify the bug. A bug may involve more than one category.
+
+Possible categories:
+- UI/layout bug
+- design-system integration bug
+- state-management bug
+- ViewModel/presenter orchestration bug
+- business-logic bug
+- mapper/model-boundary bug
+- persistence/cache/reload bug
+- navigation/back-stack bug
+- lifecycle/re-entry bug
+- coroutine/flow/cancellation bug
+- concurrency/race-condition bug
+- platform-specific bug
+- backend-contract/parsing bug
+- permissions/session/auth bug
+
+### Step 2: Inspect before editing
+Before changing code, inspect the end-to-end path relevant to the bug.
+
+At minimum inspect:
+- screen/composable(s) involved
+- state holder / ViewModel / presenter
+- relevant UI models
+- domain/use-case logic if present
+- repository/data source path if relevant
+- persistence/storage/entity/DTO models if relevant
+- mappers between storage/domain/UI if relevant
+- navigation/lifecycle/re-entry behavior if relevant
+- source-set placement if any platform-specific code is involved
+- existing tests around the affected flow
+
+Do not jump to a UI-only fix if the bug may come from state, mapping, persistence, or lifecycle.
+
+### Step 3: Diagnose root-cause candidates
+Before implementing, produce a short diagnosis:
+1. observed behavior
+2. expected behavior
+3. likely root-cause candidates
+4. layers involved
+5. files likely to change
+6. chosen fix strategy
+7. risks / edge cases
+
+If multiple causes are plausible, choose the most evidence-based one and verify it against the code.
+
+### Step 4: Implement the smallest correct fix
+Apply only the changes needed to fix the bug safely.
+
+Prefer:
+- fixing the root cause instead of masking the symptom
+- extracting a small mapper/helper only if needed
+- preserving existing public APIs unless change is required
+- keeping the diff easy to review
+
+### Step 5: Add regression protection
+Where appropriate, add or update tests for:
+- pure bug-triggering logic
+- mapper/model conversion issues
+- state transitions
+- repository coordination
+- parsing/serialization issues
+- concurrency-sensitive behavior
+
+Do not add noisy tests for trivial wiring.
+
+---
+
+## Root-cause rules by bug type
+
+### UI/layout bugs
+For layout, keyboard, scrolling, spacing, visibility, clipping, or overlapping issues:
+- inspect insets handling before adding padding
+- inspect scaffold/content padding
+- inspect duplicate `imePadding`, `navigationBarsPadding`, or bottom padding
+- inspect list content padding vs composer/input bar spacing
+- inspect scroll state ownership
+- inspect whether the bug happens only on first entry, re-entry, or keyboard transitions
+- inspect whether state timing is causing the UI symptom
+
+Do not assume the UI layout is the sole cause if the issue appears after navigation or re-entry.
+
+### State-management bugs
+For wrong loading/error/success behavior, stale content, impossible states, or wrong transient effects:
+- inspect state ownership
+- inspect whether one-time effects are mixed into persistent state
+- inspect whether multiple async paths can mutate the same state inconsistently
+- inspect stale response handling
+- inspect whether UI is deriving too much logic locally
+
+Prefer explicit state transitions over ad hoc boolean combinations.
+
+### Mapper/model-boundary bugs
+If data displays correctly initially but breaks after reload/re-entry:
+- inspect storage model
+- inspect serialization/deserialization
+- inspect entity ↔ domain ↔ UI mapping
+- inspect whether transient in-memory fields are incorrectly required for rendering
+- inspect whether content type is inferred from text instead of modeled explicitly
+
+If a persisted rich-content item becomes plain text later, treat that as a model/mapping bug first, not a UI bug.
+
+### Persistence/re-entry bugs
+If a bug appears after:
+- navigating away and back
+- screen recreation
+- process recreation
+- retry/reload
+- app restart
+
+Then inspect persistence/cache/source-of-truth behavior before changing UI rendering.
+
+Be explicit about:
+- source of truth
+- reload path
+- mapper behavior on restored data
+- stale cache behavior
+- local vs remote precedence
+
+### Coroutine/flow/cancellation bugs
+If the bug involves loading stuck forever, duplicate events, missing updates, or inconsistent async behavior:
+- inspect coroutine scope ownership
+- inspect cancellation handling
+- inspect `catch` / `runCatching`
+- inspect `StateFlow` / `SharedFlow` usage
+- inspect duplicate collectors
+- inspect race conditions between refresh, send, retry, and navigation
+
+Do not swallow `CancellationException`.
+Do not fix timing bugs with brittle arbitrary delays unless absolutely unavoidable.
+
+### Concurrency/race-condition bugs
+If the bug depends on timing or overlapping actions:
+- inspect repeated taps
+- inspect duplicate submissions
+- inspect stale responses overriding fresh state
+- inspect multiple jobs writing to the same state
+- inspect whether latest-wins / first-wins behavior is defined
+- inspect scroll-after-update timing carefully in chat/list UIs
+
+### Navigation/lifecycle bugs
+If behavior changes on re-entry, deep link entry, back navigation, or app resume:
+- inspect route arguments
+- inspect state restoration
+- inspect screen recreation behavior
+- inspect whether the state holder is recreated unexpectedly
+- inspect whether lifecycle-side effects run too often or not enough
+
+### Platform-specific bugs
+If the bug may differ between Android and iOS:
+- verify whether the logic belongs in shared code or platform code
+- inspect source-set placement
+- inspect bridge code / expect-actual / injected platform adapter if present
+- avoid moving shared logic into platform code unless actually necessary
+
+---
+
+## Architecture rules during bug fixing
+
+### Keep business logic out of composables
+Do not fix business/state bugs by pushing more logic into composables.
+
+### Keep ViewModels focused
+Do not let bug fixes turn the ViewModel into a dumping ground.
+If the fix requires more logic, consider:
+- mapper extraction
+- validator extraction
+- state transformer extraction
+- small helper extraction
+- domain extraction if the bug exposed real business-logic complexity
+
+### Preserve model boundaries
+Do not leak:
+- DTOs into UI
+- persistence entities into presentation
+- platform-specific APIs into shared code
+
+### Preserve module boundaries
+Do not bypass the owning module’s API just to land a quick fix.
+
+---
+
+## Shared UI system rules
+
+While fixing bugs, preserve the app’s existing UI conventions.
+
+Use and preserve:
+- shared UI components already in the codebase
+- spacing tokens and shared styling abstractions when the project has them
+- existing typography/theme patterns
+- existing strings/localization conventions
+
+Avoid:
+- hardcoded spacing values
+- ad hoc styling
+- hardcoded user-facing strings
+- replacing shared components with raw primitives unless required for the fix
+
+---
+
+## Compose bug-fixing rules
+
+When fixing Compose UI bugs:
+
+### Recomposition and state
+Check for:
+- unstable parameters
+- broad state observation
+- recreated lambdas/objects
+- derived values recalculated repeatedly
+- stale captured values in effects
+
+### Side effects
+Check:
+- `LaunchedEffect` keys
+- `DisposableEffect` cleanup
+- `snapshotFlow` usage
+- scroll timing relative to data updates
+- lifecycle-safe triggering of effects
+
+### Lazy lists
+Check:
+- stable item keys
+- bottom padding correctness
+- content padding vs input bar overlap
+- scrolling to the correct item after data changes
+- not repeatedly animating scroll in a janky way
+
+---
+
+## Coroutine and flow rules
+
+When fixing async bugs:
+
+- preserve structured concurrency
+- ensure work has clear ownership
+- avoid detached jobs
+- do not swallow cancellation
+- make error handling explicit
+- ensure flows are collected in the right place
+- avoid duplicate collectors
+- do not hide race conditions with superficial guards unless the coordination logic is actually correct
+
+---
+
+## Security and privacy rules
+
+If the bug touches auth, session, files, deep links, WebViews, external input, payments, or PII:
+- inspect trust boundaries first
+- do not rely on UI gating as security
+- do not expose raw internal/backend errors unnecessarily
+- avoid logging sensitive values
+- validate external input defensively
+- verify logout/session cleanup behavior if relevant
+
+Do not introduce insecure shortcuts while fixing a bug.
+
+---
+
+## Tests
+
+Add or update tests where appropriate, especially for:
+- mappers
+- state transitions
+- reload/re-entry behavior at the pure-logic level
+- repository coordination
+- parsing/serialization
+- concurrency-sensitive behavior
+
+If a bug was caused by a mapper/persistence mismatch, add a regression test for exactly that path.
+
+If a bug was caused by state timing, add the strongest test possible at the pure/state-holder level without creating brittle UI tests unnecessarily.
+
+---
+
+## Anti-patterns to prevent
+
+- fixing symptoms without identifying likely root cause
+- changing UI when the bug is actually in mapping or persistence
+- changing persistence when the bug is actually in UI state/rendering
+- large opportunistic refactors during a bug fix
+- broad cleanup mixed with the bug fix
+- timing hacks without understanding ordering/cause
+- swallowing cancellation or exceptions
+- patching around duplicate submissions instead of coordinating state properly
+- leaking DTOs/entities into UI for convenience
+- platform-specific fixes in shared code or shared fixes in platform code without justification
+- adding hardcoded strings/styling during the fix
+- leaving the bug fixed only for the happy path but broken on reload/re-entry/retry
+
+---
+
+## Required output format
+
+When using this skill, respond in this structure:
+
+1. **Bug classification**
+2. **Observed vs expected behavior**
+3. **Likely root-cause candidates**
+4. **Files/layers to inspect**
+5. **Chosen fix strategy**
+6. **Implementation**
+7. **Tests added or recommended**
+8. **Remaining risks / follow-ups**
+
+If the task is clearly an editing task, keep diagnosis concise and then proceed with the implementation.
+
+---
+
+## Final instruction
+
+Fix the bug like an architect who will have to maintain the code later.
+
+Be strict about:
+- root-cause correctness
+- minimal safe diffs
+- architecture boundaries
+- state/model integrity
+- concurrency correctness
+- persistence/re-entry safety
+- security/privacy
+- regression prevention
+
+Do not optimize for cleverness.
+Do not optimize for cleanup unrelated to the bug.
+Optimize for the smallest correct, durable fix.

+ 993 - 0
.claude/skills/kotlin-project-feature-implementation/SKILL.md

@@ -0,0 +1,993 @@
+---
+name: kotlin-project-feature-implementation
+description: Use when implementing or extending a feature in a Kotlin Multiplatform project. Provides pre-coding inspection, KMP source-set discipline, state pipeline design, architectural defaults, security/performance guardrails, and implementation rules. Forward-looking only — not a review skill.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "3.0.0"
+---
+
+# Kotlin Multiplatform Feature Implementation
+
+Use this skill when **implementing** a new feature or extending an existing flow in a Kotlin Multiplatform project.
+
+This skill is forward-looking only. It is not a review skill. It does not produce verdicts or issue severity ratings. For post-implementation or PR review, use `kotlin-project-architecture-review` instead.
+
+Your job is to deliver production-grade code that fits the existing architecture, respects the shared UI system, preserves module boundaries, and remains easy to maintain over time.
+
+Do not optimize for speed of output alone.  
+Optimize for correctness, scalability, maintainability, security, consistency, and clean evolution of the existing codebase.
+
+---
+
+## What this skill does
+
+- Provides a pre-coding inspection checklist to read the codebase before writing anything
+- States the architectural defaults to implement against
+- Gives layer-by-layer implementation rules
+- Defines KMP source-set placement discipline
+- Defines state pipeline and data ownership expectations
+- Enforces shared UI system usage
+- Adds performance, coroutine, concurrency, and security guardrails
+- Defines the expected output format for an implementation plan
+
+---
+
+## Primary goals
+
+For every implementation, optimize for:
+
+1. **Architectural consistency**
+2. **Small, coherent diffs**
+3. **Business logic in the right layer**
+4. **Strong separation of concerns**
+5. **Model and boundary integrity**
+6. **Predictable state management**
+7. **Shared UI system consistency**
+8. **Composable, reusable design**
+9. **Compose performance**
+10. **Coroutine and threading correctness**
+11. **Concurrency and race-condition safety**
+12. **Security and privacy safety**
+13. **Source-of-truth discipline**
+14. **Testability**
+15. **Migration and rollout safety**
+16. **Long-term maintainability**
+
+---
+
+## Default architecture assumptions
+
+Unless the project clearly does otherwise, implement features using:
+
+- UI driven from immutable state
+- user events flowing into a state holder
+- repositories owning data access and coordination
+- domain layer only when business logic is complex, reused, or meaningfully reduces state-holder complexity
+- shared logic in `commonMain` only when valid for all declared targets
+- platform code at the edges
+- narrow module APIs and cohesive feature ownership
+- tests alongside new logic, not deferred
+
+Do not invent new structure if the codebase already has a valid one. Match conventions unless there is a clear reason not to, and state that reason explicitly in the implementation plan.
+
+---
+
+## Implementation philosophy
+
+Before writing code, understand how the existing app already solves similar problems.
+
+Do not introduce parallel patterns unless the current pattern is clearly broken and the task explicitly requires changing it.
+
+Default behavior:
+- preserve existing architecture
+- extend existing modules rather than creating shadow flows
+- reuse existing components before creating new ones
+- make the smallest coherent implementation that solves the task correctly
+- keep business logic out of UI
+- avoid unrelated refactors
+- prefer explicit, readable code over clever abstractions
+
+---
+
+## Step 0: Read before writing
+
+Inspect the relevant existing feature before writing any code. Identify:
+
+1. **Module boundaries** — which modules own the feature area, and what their public APIs are
+2. **Source-set placement** — what is in `commonMain` vs platform source sets; where new code belongs
+3. **Route and navigation ownership** — where routes are defined, how new screens integrate
+4. **State-holder pattern in use** — ViewModel, presenter, state machine; what the existing contract looks like
+5. **Repository and data-source abstractions** — existing interfaces, implementations, source-of-truth rules, error model
+6. **Domain layer presence and rationale** — whether it exists, whether it adds value, whether new logic belongs there
+7. **Error-model conventions** — exceptions, result wrappers, sealed error types; what the project uses consistently
+8. **Existing tests** — test locations, test doubles, patterns already established
+9. **Shared UI system usage** — existing shared components, spacing tokens, typography patterns, strings/localization patterns
+10. **Similar flows already implemented** — find the closest existing feature and copy the pattern, not just the visual result
+
+Do not start coding before grounding the implementation in the current codebase.
+
+---
+
+## Required workflow
+
+Follow this workflow for every feature unless explicitly told otherwise.
+
+### Step 1: Inspect first
+Before implementing:
+- identify the feature/module involved
+- identify navigation entry points
+- identify existing presentation/state patterns
+- identify domain/use case patterns
+- identify repository/data/API boundaries
+- identify existing shared UI components
+- identify similar features/screens already implemented
+- identify whether the change belongs in shared code or platform-specific code
+- identify source-of-truth expectations
+- identify security/trust-boundary implications if the feature touches auth, session, deep links, payments, files, web content, roles, or PII
+
+### Step 2: Plan before editing
+Before making changes, produce a short implementation plan that includes:
+1. files to inspect
+2. files likely to change
+3. new files likely to be added
+4. business logic placement
+5. data ownership and source-of-truth decisions
+6. state pipeline changes required
+7. API/data implications
+8. source-set placement decisions
+9. risks and edge cases
+10. tests that should be added
+
+### Step 3: Implement the smallest coherent slice
+Implement only what is required for the requested slice.
+
+Prefer vertical slices such as:
+- models + mapper + repository contract
+- ViewModel/state changes
+- UI rendering for known state
+- API integration for one path
+- loading/error handling
+- one interaction flow at a time
+
+Avoid broad end-to-end rewrites unless explicitly requested.
+
+### Step 4: Self-check before finishing
+Before considering the task done, check for:
+- architecture drift
+- large ViewModels
+- logic in the wrong layer
+- DTO leakage across boundaries
+- ambiguous or contradictory state
+- shared UI system misuse
+- hardcoded strings
+- Compose recomposition risks
+- coroutine/threading issues
+- cancellation and exception handling issues
+- race conditions or duplicate-submission risks
+- source-of-truth confusion
+- security/privacy issues
+- missing tests
+
+---
+
+## Layer-by-layer implementation rules
+
+### UI layer
+
+- Render from immutable `UiState`
+- No ad hoc dependency access in composables
+- No repository or data-source calls from UI code
+- No business rules in composables
+- No DTOs or persistence models in `UiState`
+- No platform-specific APIs in shared composables
+- Handle all reachable states explicitly: loading, success, empty, error, partial-data, retry
+- Handle responsive layout needs at this layer, not in lower layers
+- Prefer stateless rendering composables where possible
+- Keep one-time effects separate from persistent UI state
+
+#### Shared UI system rules
+
+The feature must follow the existing project UI system.
+
+Use and prefer:
+- shared UI components already in the codebase
+- layout abstractions when they already exist and fit
+- spacing tokens instead of ad hoc spacing values
+- shared typography and theme styles
+- project-approved color and styling patterns
+- existing shared primitives before creating new ones
+
+Avoid:
+- hardcoded spacing/dimensions when tokens already exist
+- direct generic Compose primitives when a project abstraction already exists and fits
+- duplicate UI patterns that should become shared components
+- ad hoc styling inconsistent with the rest of the app
+
+When a new reusable UI pattern is needed:
+- extract it cleanly
+- give it a meaningful name
+- keep it focused
+- place it in the right module/file
+
+Do not over-abstract one-off UI fragments prematurely.
+
+#### Strings and localization rules
+
+Do not hardcode user-facing strings.
+
+All product-facing text should:
+- use resource-based strings according to project conventions
+- follow the app’s default product tone and language unless the task says otherwise
+- use parameterized resources where dynamic values are involved
+
+This applies to:
+- titles
+- labels
+- button text
+- placeholders
+- snackbars
+- empty states
+- error messages
+- helper text
+- accessibility text where relevant
+
+---
+
+### State holder
+
+- Expose exactly one immutable observable state stream, or the single state output pattern the project uses
+- Separate one-time effects from persistent `UiState`
+- Consume user actions/events as inputs; do not let UI coordinate work directly
+- Coordinate lower layers; do not own data-layer logic inline
+- Do not become a god object
+- Keep lifecycle/platform wiring out of shared state-holder logic unless the project explicitly puts it there
+
+A state holder is allowed to:
+- receive user intents
+- call use cases/repositories
+- coordinate screen state
+- expose state/effects
+- do lightweight mapping from domain results into UI state
+
+A state holder should not grow into:
+- a workflow engine
+- a validator bag
+- a mapper dumping ground
+- a formatting layer
+- an analytics monolith
+- a place for unrelated helper functions
+
+When complexity rises:
+- extract mappers
+- extract validators
+- extract state transformers/reducers
+- extract use cases/domain services
+- split reusable logic into separate classes/files
+
+**Pattern note:**  
+On Android, ViewModel is the standard state holder and integrates with the lifecycle natively. On KMP targets without Android ViewModel, use the equivalent presenter or state-machine pattern the project has established. The shape must remain the same: one observable state output and separate effects regardless of the underlying implementation.
+
+---
+
+### Domain layer
+
+Add a domain layer only when at least one of these is true:
+
+- the business rule is reused by more than one state holder or flow
+- the business rule is non-trivial and benefits from isolated testing
+- extracting it makes the state holder meaningfully smaller and clearer
+- the logic represents business concepts that should not live in the data or presentation layer
+
+Do not add pass-through use cases to satisfy an architecture diagram.  
+A use case that only forwards a repository call with no meaningful isolation is net-negative.
+
+Business logic should primarily live here when it is:
+- reusable
+- non-trivial
+- worth testing in isolation
+- needed to keep the state holder focused
+
+---
+
+### Data layer
+
+- Repositories expose domain-facing interfaces, not DTOs, not persistence schemas, not HTTP response shapes
+- Repositories coordinate local and remote sources internally
+- Callers should not see data-source coordination details
+- DTOs and persistence models live below the repository boundary and do not cross it upward
+- Preserve the project’s established error model consistently across new repositories
+- New data sources must have a single, narrow responsibility
+- Source-of-truth decisions must be explicit
+
+Be clear about:
+- where reads come from
+- where writes go
+- how cache/local/remote coordination works
+- how refresh/invalidation works
+- how optimistic updates reconcile, if applicable
+
+Do not merge local and remote state ad hoc in the UI/state holder unless the codebase explicitly already does that and it is justified.
+
+---
+
+### Source sets
+
+Before placing any new file in `commonMain`, confirm it is valid for all declared targets.
+
+Decision order:
+1. Does it compile and behave correctly on all targets with no platform-specific API? → `commonMain`
+2. Is it valid for a platform family? → intermediate source set such as `appleMain` or `iosMain`
+3. Does it genuinely differ per target? → platform source set with a shared abstraction in `commonMain` if needed
+
+Do not default to `expect`/`actual` before checking whether an interface plus injected implementation would be simpler.
+
+Additional rules:
+- do not place feature logic inside bridge implementations
+- do not place platform APIs in shared code
+- prefer shared business logic, mapping, validation, and state logic when it truly applies to all targets
+- prefer platform-specific placement for OS integrations, permissions, storage APIs, platform navigation adapters, native SDK interop, or target-specific lifecycle wiring
+
+---
+
+### Module boundaries
+
+- Keep feature changes local to the owning module wherever possible
+- Do not bypass module APIs for implementation convenience
+- If a change requires touching many modules, treat that as a signal that boundaries may need review; flag it in the plan
+- New public module APIs should be as narrow as needed and no wider
+- Do not let one feature reach directly into another feature’s internal implementation details
+
+---
+
+### Navigation
+
+- Follow the route model and entry-point patterns already established
+- Do not re-architect navigation as part of a feature implementation unless explicitly scoped
+- Preserve back/up behavior that matches user expectations
+- Model deep-link entry realistically: the back stack after entry should be coherent, not empty
+- Keep navigation decisions explicit and predictable
+- Do not mix navigation events into persistent state
+
+---
+
+## Model and boundary rules
+
+Each layer should speak in the right model type.
+
+### Data layer models
+Use:
+- DTOs / transport models
+- persistence entities
+- remote/local data-source models
+
+Do not leak these upward casually.
+
+### Domain layer models
+Use:
+- business-oriented models
+- use case input/output models
+- validation/business concepts
+
+Do not pollute domain models with:
+- Compose/UI concerns
+- persistence-only concerns
+- transport-specific details
+
+### Presentation layer models
+Use:
+- explicit UI state models
+- screen-specific UI models when needed
+
+Do not pass a single “god model” through all layers just to reduce mapping work.
+
+### Mapping rules
+Mapping responsibilities must be:
+- explicit
+- predictable
+- easy to discover
+- easy to test
+
+If a screen requires a screen-specific projection, create the right UI model instead of overloading a domain model.
+
+---
+
+## State management rules
+
+State must be predictable and testable.
+
+### Prefer explicit state
+Avoid ambiguous collections of booleans that create contradictory states.
+
+Prefer:
+- clear state data classes
+- sealed sub-states when appropriate
+- explicit fields with clear ownership
+- state transformations that are easy to follow
+
+### Separate durable state from transient effects
+Do not mix:
+- screen state
+- navigation events
+- snackbars/toasts
+- permission requests
+- one-time confirmations or one-time errors
+
+Handle transient effects explicitly and safely.
+
+### State ownership
+A screen’s state should have clear ownership.
+Do not let multiple unrelated async paths mutate shared state in ways that are hard to reason about.
+
+### Avoid impossible states
+Always ask:
+- can this screen end up loading and success and blocking error at once?
+- can stale data remain visible incorrectly?
+- can a retried request corrupt the state?
+- can multiple async updates interleave unpredictably?
+
+---
+
+## Data ownership decisions
+
+For each new data type or flow, define:
+
+- source of truth
+- who owns reads
+- who owns writes
+- whether there is caching
+- whether offline behavior matters
+- how refresh/retry/invalidation works
+- whether partial data is acceptable
+- whether optimistic updates exist and how they reconcile
+
+Do not create multiple writable sources of truth for the same data type unless this is clearly intentional and carefully coordinated.
+
+---
+
+## State pipeline design
+
+For each feature flow, define:
+
+- `UiState` shape
+- user actions/events
+- one-time effects
+- loading → success path
+- loading → error path
+- retry path
+- empty-state handling
+- partial-data handling
+- stale-response handling if multiple requests can overlap
+
+State transitions should be explicit and easy to test.
+
+---
+
+## Compose implementation rules
+
+### 1. Compose is for rendering, not heavy work
+Do not do heavy or repeated work directly inside composables.
+
+Avoid in composition:
+- large filtering/sorting/mapping chains
+- expensive derived calculations
+- repeated object creation that could be stabilized
+- broad state observation when a smaller slice is enough
+
+Prefer:
+- precomputed UI state
+- smaller composables
+- stable UI models
+- `remember` only when justified
+- `derivedStateOf` only when it materially helps
+- state hoisting where appropriate
+
+### 2. Recomposition discipline
+Implement UI in ways that reduce unnecessary recomposition.
+
+Watch for:
+- unstable parameters
+- lambdas recreated unnecessarily
+- list items depending on overly broad parent state
+- derived values recalculated every recomposition
+- collecting state too high in the tree
+
+### 3. Side-effects correctness
+Use side-effect APIs intentionally.
+
+Be careful with:
+- `LaunchedEffect`
+- `DisposableEffect`
+- `SideEffect`
+- `rememberCoroutineScope`
+- `snapshotFlow`
+
+Do not:
+- launch work from composition without lifecycle reasoning
+- use incorrect keys
+- accidentally restart work
+- capture stale values
+
+### 4. Lazy list discipline
+For lists:
+- use stable keys where appropriate
+- extract item content cleanly
+- avoid expensive per-item computation in composition
+- avoid re-rendering whole lists due to broad state coupling
+
+---
+
+## Coroutine and threading rules
+
+### 1. Use the right dispatcher
+Do not leave expensive work ambiguously on main thread.
+
+Be explicit about:
+- IO/network work
+- database/persistence work
+- CPU-heavy transformations
+- testable dispatcher injection if the project pattern expects it
+
+### 2. Respect structured concurrency
+All async work should have clear ownership and lifecycle.
+
+Avoid:
+- detached jobs
+- work that outlives the screen/feature scope unintentionally
+- nested launches that obscure cancellation or sequencing
+
+### 3. Preserve cancellation semantics
+Do not accidentally swallow cancellation.
+
+Be careful with:
+- broad `catch`
+- blanket `runCatching`
+- generic failure wrappers that also catch `CancellationException`
+
+Cancellation is not a normal failure and should usually propagate.
+
+### 4. Error handling must be intentional
+Do not silently fail.
+
+Prefer:
+- explicit error mapping
+- clear UI failure states
+- domain-level error modeling where useful
+- observability for important failures
+
+### 5. Flow usage must be intentional
+Use the right abstraction:
+- `Flow`
+- `StateFlow`
+- `SharedFlow`
+
+Avoid:
+- duplicate collectors without need
+- replay misuse
+- expensive transformations duplicated per collector
+- collecting raw streams in UI when state should already be prepared upstream
+
+---
+
+## Concurrency and race-condition rules
+
+Assume async interactions can race unless you deliberately prevent it.
+
+Design flows to handle:
+- repeated taps
+- duplicate submissions
+- stale responses arriving after newer ones
+- retries
+- refresh while another request is in flight
+- partial failures
+- concurrent state updates
+
+Prefer:
+- explicit coordination
+- deterministic update rules
+- idempotent or guarded submit behavior
+- latest-wins or first-wins semantics chosen intentionally
+- debounce/throttle where necessary
+
+---
+
+## Security and privacy rules
+
+Treat trust boundaries seriously.
+
+### Never assume UI gating is real authorization
+Do not rely on hidden buttons or client-side checks as security.
+
+### Handle sensitive data minimally
+Avoid:
+- exposing tokens unnecessarily
+- passing PII through layers that do not need it
+- logging sensitive values
+- including sensitive data in analytics or crash reporting
+- caching sensitive data casually
+
+### Be defensive with external input
+Be careful with:
+- deep links
+- URLs
+- WebViews
+- file/URI handling
+- backend-provided text/data
+- user-provided input
+- route parameters
+
+Validate and parse defensively.
+
+### Session/auth safety
+Consider:
+- stale session state
+- logout cleanup
+- resume/re-entry paths
+- expired auth
+- privileged state left hanging after session changes
+
+### Error exposure
+Do not surface raw backend/internal errors directly to users unless explicitly appropriate.
+
+---
+
+## Dependency injection and construction rules
+
+Use the project’s DI pattern consistently.
+
+Avoid:
+- ad hoc construction of important collaborators inside feature code
+- hidden service locator patterns
+- singleton everything
+- stateful dependencies with overly broad scope
+
+Prefer:
+- explicit dependency injection
+- scope aligned to ownership/lifecycle
+- construction that is easy to test
+- small, well-defined dependency surfaces
+
+---
+
+## Reusability and file organization rules
+
+### Reuse before creating
+Before introducing a new component/helper/mapper:
+- check for an existing one
+- extend or adapt existing patterns if appropriate
+
+### Extract only real reuse
+Extract reusable code when:
+- duplication is real
+- the abstraction has a clear name
+- it improves maintainability
+
+Do not create generic abstractions too early.
+
+### Keep files focused
+Prefer:
+- one meaningful class/component/mapper/validator per file when appropriate
+- discoverable organization
+- focused files
+
+Avoid:
+- giant files with multiple unrelated responsibilities
+- helper types buried inside unrelated files
+- dumping many unrelated private utilities together
+
+---
+
+## Internal API design rules
+
+Treat internal code as APIs for future maintainers.
+
+Prefer:
+- intention-revealing names
+- narrow interfaces
+- immutable public surfaces by default
+- parameters that are hard to misuse
+- cohesive responsibilities
+
+Avoid:
+- boolean parameter smells
+- broad signatures
+- APIs that force callers to know too much
+- weak naming
+- mutable public state unless truly needed
+
+---
+
+## Tests
+
+Write tests alongside implementation, not after.
+
+At minimum, test:
+- state-holder transitions for the new flow
+- loading → success
+- loading → error
+- retry
+- empty/partial-data handling where relevant
+- business rules in new domain use cases, if any
+- repository coordination logic, mappers, and error-handling paths
+- navigation decision logic for any conditional routing
+- parsing/serialization or boundary mapping where important
+- concurrency-sensitive behavior where multiple async paths interact
+
+Use shared tests where logic is shared.  
+Use platform-specific test infrastructure only where the code is actually platform-specific.
+
+Do not defer meaningful tests unless explicitly told to.
+
+---
+
+## Observability rules
+
+For important flows, make failures diagnosable.
+
+Prefer:
+- meaningful logs where the project expects them
+- explicit failure handling
+- useful diagnostics for critical paths
+- privacy-safe logging and analytics
+
+Avoid:
+- silent failures
+- vague catch-and-ignore code
+- noisy logs with low value
+- logs that expose sensitive data
+
+---
+
+## Backward compatibility and rollout rules
+
+Implement code so it can survive:
+- partial rollout
+- evolving backend schemas
+- missing fields
+- unknown enum values
+- partially available backend functionality
+- migration-sensitive local persistence changes
+
+Prefer:
+- tolerant parsing where appropriate
+- graceful degradation
+- feature isolation
+- rollout-safe assumptions
+
+Do not assume all clients, servers, and data are updated simultaneously.
+
+---
+
+## Accessibility and UX robustness rules
+
+Implement UI that behaves well under:
+- slow network
+- partial data
+- empty states
+- errors
+- retries
+- disabled states
+
+Ensure:
+- users get feedback for important actions
+- retry/recovery paths exist when needed
+- degraded states are understandable
+- accessibility semantics are added where relevant and supported by the existing pattern
+
+---
+
+## KMP-specific rules
+
+Because this is KMP, always consider:
+
+### Common vs platform-specific placement
+Prefer common code unless platform-specific behavior is actually required.
+
+Do not introduce platform divergence casually.
+
+### Portability
+Avoid APIs or patterns that make shared code harder to port, test, or maintain.
+
+### Cross-platform behavior consistency
+Consider whether the implementation will behave consistently on iOS and Android.
+
+### Cross-platform threading assumptions
+Do not assume behavior that only makes sense for one target.
+
+---
+
+## Recommended feature structure
+
+When the project has no established feature structure, this shape is a reasonable default:
+
+```text
+feature-<name>/
+  presentation/
+    <FeatureName>Screen.kt
+    <FeatureName>ViewModel.kt
+    <FeatureName>UiState.kt
+    <FeatureName>UiAction.kt
+    <FeatureName>UiEffect.kt
+    <FeatureName>Route.kt
+  domain/
+    <FeatureName>UseCase.kt
+    <FeatureName>Model.kt
+  data/
+    <FeatureName>Repository.kt
+    <FeatureName>RepositoryImpl.kt
+    <FeatureName>RemoteSource.kt
+    <FeatureName>LocalSource.kt
+    <FeatureName>Dto.kt
+    <FeatureName>Mapper.kt
+  di/
+    <FeatureName>Module.kt
+```
+
+The folder shape is not the goal. Cohesive ownership and predictable placement are. Match existing structure unless it is clearly broken.
+
+---
+
+## Lint and static analysis expectations
+
+Write code as if it must pass:
+- ktlint
+- detekt
+- Android/Kotlin lint
+- Compose-specific static analysis where relevant
+
+Avoid:
+- long methods
+- long files
+- high complexity
+- deep nesting
+- magic numbers
+- dead code
+- poor visibility choices
+- nullable misuse
+- misleading scope-function usage
+- unused helpers/imports
+- weak naming
+
+---
+
+## Anti-patterns to prevent
+
+- starting to write code before reading the existing structure
+- multiple writable sources of truth for the same data type
+- direct repository or data-source access from composables
+- pass-through use cases that add ceremony without isolation benefit
+- DTOs or persistence models flowing into `UiState`
+- platform-specific APIs in `commonMain`
+- feature logic embedded in bridge implementations
+- treating loading, error, empty, and partial-data states as afterthoughts
+- inconsistent error modeling relative to the rest of the codebase
+- new module APIs wider than the feature needs
+- massive ViewModels
+- business logic in composables
+- broad state observation causing extra recomposition
+- blocking work on main
+- swallowing cancellation
+- unstructured coroutine launches
+- race conditions in submit/refresh flows
+- duplicated UI patterns that should be components
+- giant files with mixed responsibilities
+- insecure token/session handling
+- sensitive data in logs
+- unclear source of truth
+- brittle schema assumptions
+- APIs that are easy to misuse
+- hardcoded user-facing strings
+- raw spacing/dimensions instead of design tokens where the system already provides them
+
+---
+
+## Required output format
+
+When using this skill to guide implementation, produce:
+
+1. **Pre-coding inspection summary**
+   - modules and source sets affected
+   - state-holder pattern in use
+   - repository/data-source conventions observed
+   - domain layer presence and rationale
+   - error model in use
+   - existing UI system patterns
+   - existing test patterns
+
+2. **Data ownership decisions**
+   - source of truth for each new data type
+   - who owns writes, who owns reads
+   - offline/cache considerations if relevant
+
+3. **State pipeline design**
+   - `UiState` shape
+   - user actions/events
+   - one-time effects
+   - loading → success
+   - loading → error
+   - retry
+   - empty state
+   - partial-data path if relevant
+
+4. **Layer plan**
+   - which files to create or modify
+   - in which module
+   - in which source set
+   - what stays in shared code vs platform code
+   - domain layer: yes/no and why
+
+5. **Implementation**
+   - code changes
+
+6. **Tests to add**
+   - state-holder transition tests
+   - domain use case tests if applicable
+   - repository/mapper tests
+   - concurrency/boundary tests if needed
+   - which test source set each test lives in
+
+7. **Risks and edge cases**
+   - source-set correctness risks
+   - navigation edge cases
+   - partial-data and error-path risks
+   - concurrency/retry risks
+   - security/trust-boundary risks
+   - configuration/lifecycle edge cases
+
+8. **Assumptions**
+   - any architectural assumption made that is not directly verified in the codebase
+
+If the task is clearly an editing task and not just analysis, keep the plan concise and then proceed with implementation.
+
+---
+
+## Default response structure
+
+When asked to implement a feature, respond in this structure unless told otherwise:
+
+1. **Pre-coding inspection summary**
+2. **Data ownership decisions**
+3. **State pipeline design**
+4. **Layer plan**
+5. **Risks and edge cases**
+6. **Implementation**
+7. **Tests added or recommended**
+8. **Assumptions**
+
+---
+
+## Final instruction
+
+Implement like an architect who will have to maintain this code for years.
+
+Be strict about:
+- correctness
+- architecture boundaries
+- scalability
+- maintainability
+- consistency
+- performance
+- security
+- privacy
+- testability
+- rollout safety
+
+Do not optimize for cleverness.  
+Do not optimize for broad refactors.  
+Optimize for clean, production-grade evolution of the existing codebase.
+
+---
+
+## References
+
+- Android architecture recommendations — https://developer.android.com/topic/architecture/recommendations
+- Android UI layer — https://developer.android.com/topic/architecture/ui-layer
+- Android domain layer — https://developer.android.com/topic/architecture/domain-layer
+- Android data layer — https://developer.android.com/topic/architecture/data-layer
+- Android modularization — https://developer.android.com/topic/modularization
+- Android navigation principles — https://developer.android.com/guide/navigation/principles
+- Android configuration changes — https://developer.android.com/guide/topics/resources/runtime-changes
+- Kotlin Multiplatform project structure — https://kotlinlang.org/docs/multiplatform/multiplatform-discover-project.html
+- Compose Multiplatform — https://kotlinlang.org/docs/multiplatform/compose-multiplatform.html
+- Navigation in Compose Multiplatform (Navigation 2, stable) — https://kotlinlang.org/docs/multiplatform/compose-navigation.html
+- Navigation 3 in Compose Multiplatform (alpha as of mid-2025 — verify before adopting) — https://kotlinlang.org/docs/multiplatform/compose-navigation-3.html

+ 417 - 0
.claude/skills/kotlin-project-modularization/SKILL.md

@@ -0,0 +1,417 @@
+---
+name: kotlin-project-modularization
+description: Use when designing, reviewing, or refactoring module boundaries in KMP or Android projects — feature, data, app, and common modules, dependency direction, visibility control, and granularity.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "1.1.0"
+---
+
+# Kotlin Multiplatform Modularization
+
+Use this skill when designing, reviewing, or refactoring module boundaries in a Kotlin Multiplatform project.
+
+This skill is intentionally strict. Its purpose is to keep module boundaries meaningful, dependency direction clean, ownership clear, visibility tight, and project growth manageable.
+
+## Primary goals
+
+The modularization strategy should optimize for:
+
+- reusability
+- strict visibility control
+- scalability
+- ownership
+- encapsulation
+- testability
+- build performance
+- clear app entry points
+- separation of platform-specific dependencies from reusable code
+
+Do not modularize only for aesthetics.
+Modularization should create clearer ownership and lower coupling.
+
+---
+
+## Official defaults to prefer
+
+Unless the codebase has a strong reason not to, prefer:
+
+- modules with a clear purpose
+- narrow public APIs
+- hidden implementation details
+- feature ownership that is easy to infer
+- shared code extracted only when it is truly shared
+- module boundaries that reduce coupling
+- app modules as entry points
+- feature modules aligned to screens or closely related flows
+- data modules that expose repositories and hide data sources
+- common/core modules only for code reused by many modules
+- Kotlin/Java modules instead of Android modules where Android resources, assets, and manifest support are unnecessary
+
+---
+
+## Benefits to review for
+
+A modular design is stronger when it improves:
+
+- **Reusability**: modules act as building blocks and can be reused across variants or apps
+- **Strict visibility control**: internal details stay hidden outside the module
+- **Scalability**: changes stay local instead of cascading widely
+- **Ownership**: one team or person can clearly own a module
+- **Encapsulation**: each module knows as little as possible about others
+- **Testability**: logic can be tested in isolation
+- **Build time**: Gradle can better leverage parallelism, caching, and incremental builds
+
+If the proposed modularization does not improve at least some of these, be skeptical.
+
+---
+
+## Module types to recognize
+
+### 1. Data modules
+
+A data module usually contains:
+- repositories
+- data sources
+- model classes
+
+Its primary responsibilities are:
+- encapsulate all data and business logic of a certain domain
+- expose the repository as the external API
+- hide implementation details and data sources from the outside
+
+Review expectations:
+- repositories are the public surface
+- data sources stay internal to the module
+- data ownership is domain-oriented, not endpoint-oriented
+- callers do not depend directly on local/remote sources
+
+Flag as a concern when:
+- a data module exposes transport or persistence details publicly
+- repositories are thin pass-through wrappers
+- multiple unrelated domains are bundled together without a good reason
+
+### 2. Feature modules
+
+A feature module is an isolated part of app functionality, usually a screen or a closely related flow.
+
+Review expectations:
+- a feature corresponds to a user-visible capability
+- a feature module likely contains UI plus a ViewModel/state-holder
+- feature modules depend on data modules
+- feature ownership is obvious from the module name and content
+
+Flag as a concern when:
+- one feature is scattered across many unrelated modules
+- feature modules contain unrelated cross-cutting concerns
+- features depend on each other too directly and too broadly
+
+### 3. App modules
+
+App modules are application entry points.
+
+Review expectations:
+- app modules depend on feature modules
+- app modules usually provide root navigation
+- if multiple device types are targeted, separate app modules may be appropriate to isolate platform-specific dependencies
+
+Flag as a concern when:
+- app modules own feature business logic that should live lower
+- platform-specific concerns are not separated where they should be
+- app modules become oversized containers for unrelated code
+
+### 4. Common or core modules
+
+Common/core modules contain code that many modules reuse and do not represent one specific app layer by themselves.
+
+Examples may include:
+- UI/design system
+- analytics
+- networking infrastructure
+
+Review expectations:
+- common modules exist because the code is genuinely reused
+- they do not become dumping grounds
+- their APIs stay focused and narrow
+
+Flag as a concern when:
+- `core`, `common`, or `shared` modules accumulate unrelated responsibilities
+- code is moved into common modules too early
+- common modules become backdoors that weaken feature boundaries
+
+---
+
+## Review dimensions
+
+### 1. Module purpose clarity
+
+Check whether every module has a clear purpose.
+
+Questions:
+- Can the module be described in one sentence?
+- Is the module organized around a domain, feature, platform entry point, or a clearly shared capability?
+- Would a new developer understand why it exists?
+
+Flag as a concern when:
+- the module name is vague
+- the module mixes several unrelated purposes
+- boundaries are justified only by folder convenience
+
+### 2. Dependency direction
+
+Check whether dependencies flow in a clean direction.
+
+Prefer:
+- app modules -> feature modules -> data/common modules
+- feature modules depending on data modules, not vice versa
+- shared infrastructure dependencies flowing inward without creating feature coupling
+
+Flag as a concern when:
+- feature modules depend on each other cyclically
+- lower-level modules depend on UI or app modules
+- module relationships are unclear or brittle
+
+### 3. Visibility control
+
+Use module boundaries to hide internals aggressively.
+
+Check whether:
+- implementation details are not publicly exposed without reason
+- `internal` or `private` visibility is used appropriately
+- only the intended API of the module is consumable
+
+Flag as a concern when:
+- everything is public by default
+- repositories, data sources, mappers, and internals all leak outward
+- module boundaries exist but do not enforce encapsulation
+
+### 4. Granularity
+
+Granularity must be intentional.
+
+The Android modularization guide (https://developer.android.com/topic/modularization) identifies these pitfalls:
+- **too fine-grained**: too much overhead, build complexity, and boilerplate
+- **too coarse-grained**: modules become mini-monoliths and lose modular benefits
+- **too complex**: modularization overhead outweighs benefits for the size of the project
+
+Check whether:
+- the number of modules matches the size and complexity of the codebase
+- splitting adds clarity rather than ceremony
+- module count is justified by ownership, reuse, or coupling reduction
+
+Flag as a concern when:
+- module count explodes without real boundary value
+- large modules still contain many unrelated features
+- the build becomes harder to reason about than the code itself
+
+### 5. Feature ownership cohesion
+
+Check whether a feature mostly lives in its owning module(s).
+
+Prefer:
+- feature UI, state-holder logic, and feature coordination staying together
+- dependencies on shared modules only where they are actually shared
+- modules that let feature work stay local
+
+Flag as a concern when:
+- feature implementation requires touching many unrelated modules
+- feature ownership is hidden by broad technical slicing
+- multiple modules partially own the same feature with no clear leader
+
+### 6. Data ownership cohesion
+
+Check whether data domains are modularized coherently.
+
+Prefer:
+- data modules aligned to meaningful domains
+- repositories as the public API
+- data sources hidden internally
+
+Flag as a concern when:
+- every endpoint gets its own module without domain cohesion
+- data modules expose source internals
+- the same domain logic is split across several modules arbitrarily
+
+### 7. Common-module discipline
+
+Common modules should reduce redundancy, not centralize chaos.
+
+Check whether:
+- common modules are truly reused by multiple consumers
+- the module API is stable and small
+- feature-specific code is not prematurely generalized
+
+Flag as a concern when:
+- common modules become default homes for code with unclear ownership
+- teams use common modules to avoid choosing proper feature ownership
+- changes to one feature require unrelated common-module edits
+
+### 8. App-entry-point discipline
+
+App modules should act as entry points.
+
+Check whether:
+- root navigation lives in app modules or a clearly designated top-level integration layer
+- app modules compose features instead of owning their internal business logic
+- device-specific entry points are separated when the project targets multiple device types
+
+Flag as a concern when:
+- app modules become giant orchestration layers for all business logic
+- platform/device concerns are mixed in a single entry point despite differing requirements
+
+### 9. Build and dependency hygiene
+
+Check whether the module graph supports maintainability and build performance.
+
+Prefer:
+- `implementation` by default unless public API exposure is required
+- fewer unnecessary transitive exposures
+- module boundaries that support isolated rebuilds and isolated tests
+
+Flag as a concern when:
+- `api` is overused
+- implementation details leak through public dependency surfaces
+- the module graph forces many rebuilds for small changes
+
+### 10. Prefer the lightest correct module type
+
+Android library modules add overhead because they carry resources, assets, manifest, and AGP compilation. Use the lightest module type that correctly models the code's role:
+
+- **KMP multiplatform library modules** (`kotlin("multiplatform")` plugin): the correct vehicle for shared KMP code targeting multiple platforms (Android, iOS, desktop, web). Not the same as a plain JVM or Android module.
+- **Plain Kotlin/JVM modules** (`kotlin("jvm")`): suitable for pure JVM logic with no Android or multiplatform needs (e.g., a build-logic module or a server-side module in the same build).
+- **Android library modules** (`com.android.library`): reserved for code that genuinely needs Android resources, assets, or manifest support and is not shared to non-Android targets.
+- **Android application modules** (`com.android.application`): app entry points only.
+
+Check whether:
+- KMP shared code uses the `kotlin("multiplatform")` plugin, not the Android library plugin
+- Android library modules are reserved for code that actually needs Android-specific packaging
+- plain Kotlin/JVM modules are used where neither Android packaging nor multiplatform targeting is required
+- the module type choice is intentional rather than defaulted
+
+Flag as a concern when:
+- Android library modules are used by default for logic that could live in a KMP multiplatform module
+- shared KMP logic is placed in Android-only modules without a clear reason
+- the build overhead (resources, manifest, AGP) does not match the module's actual role
+
+### 11. Testability as a modularization outcome
+
+Check whether modularization improves isolated testing.
+
+Prefer:
+- module APIs that can be faked or mocked cleanly
+- business logic isolated away from app entry points
+- tests that can run at module scope instead of only at app scope
+
+Flag as a concern when:
+- modularization does not improve isolation
+- critical flows still require app-wide integration tests for simple validation
+- module contracts are too broad or unclear to test well
+
+---
+
+## Severity framework
+
+### High severity
+Likely to cause structural problems.
+
+Examples:
+- cyclic dependencies
+- modules with no clear purpose
+- app modules containing feature/business internals
+- data modules exposing data-source internals
+- common modules acting as dumping grounds
+
+### Medium severity
+Workable, but likely to add maintenance cost.
+
+Examples:
+- granularity slightly too fine or too coarse
+- overuse of `api`
+- weak ownership boundaries
+- feature logic spread across too many modules
+- Android modules used where plain Kotlin modules would suffice
+
+### Low severity
+Structurally acceptable but worth improving.
+
+Examples:
+- naming obscures intent
+- a common module could be split later
+- a feature boundary is slightly awkward but still understandable
+
+---
+
+## Required output format
+
+When performing the review, respond with:
+
+1. **Modularization summary**
+   - current module types
+   - dependency direction
+   - entry-point modules
+   - shared/common modules
+
+2. **What is structurally sound**
+   - concrete strengths only
+
+3. **Issues by review dimension**
+   - module purpose
+   - dependency direction
+   - visibility control
+   - granularity
+   - feature ownership
+   - data ownership
+   - common-module discipline
+   - app-entry-point discipline
+   - build/dependency hygiene
+   - Android vs plain Kotlin module choice
+   - testability
+
+4. **Severity for each issue**
+   - high / medium / low
+
+5. **Concrete recommendations**
+   - exact module moves or merges
+   - which modules should split or consolidate
+   - which APIs should become internal
+   - where dependency direction should change
+   - where a plain Kotlin module should replace an Android module
+
+6. **Suggested target structure**
+   - proposed module graph if useful
+
+7. **Open risks**
+   - migration cost
+   - build impact
+   - rollout and compatibility concerns
+
+---
+
+## Tone
+
+Be direct and practical.
+Do not praise modularization just because many modules exist.
+If the modularization is weak, say why clearly.
+
+---
+
+## Anti-patterns to flag aggressively
+
+- cyclic dependencies
+- modules with unclear purpose
+- too-fine-grained modules that add overhead without stronger boundaries
+- too-coarse-grained modules that behave like mini-monoliths
+- common/core/shared dumping grounds
+- data modules exposing data sources publicly
+- feature modules depending broadly on other feature modules
+- app modules owning feature internals
+- overuse of `api`
+- Android library modules used when KMP multiplatform library modules or plain Kotlin modules would suffice
+
+---
+
+## References
+
+- Android modularization guide: https://developer.android.com/topic/modularization
+- Android modularization patterns: https://developer.android.com/topic/modularization/patterns
+- Kotlin Multiplatform DSL reference: https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-dsl-reference.html

+ 447 - 0
.claude/skills/kotlin-project-state-management/SKILL.md

@@ -0,0 +1,447 @@
+---
+name: kotlin-project-state-management
+description: Use when choosing, implementing, or reviewing state-holder patterns in a KMP project — ViewModel, shared presenter, MVI, or StateFlow-in-common — including effect handling, UiState modeling, and testability.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "1.0.0"
+---
+
+# Kotlin Multiplatform State Management
+
+Use this skill when choosing, implementing, or reviewing how UI state is owned and produced in a Kotlin Multiplatform project.
+
+State management sits at the intersection of every other KMP architectural concern. How state is held, where it lives in source sets, and how it integrates with each platform's lifecycle determines the testability, predictability, and long-term maintainability of the entire UI layer.
+
+This skill is intentionally precise. It distinguishes between patterns clearly, explains when each is appropriate, and flags common mistakes that look correct at first but create problems at scale.
+
+## What this skill covers
+
+- The invariant state contract that all valid state-holder patterns must satisfy
+- The main pattern families available in KMP and what each costs
+- Platform-specific differences that affect state-holder choice
+- Effect handling: one-time events vs persistent state
+- State modeling: how to shape `UiState` correctly
+- Testability implications of each choice
+- Anti-patterns in each category
+
+## What this skill does not cover
+
+- Navigation (see `kotlin-navigation-compose-multiplatform`)
+- Data-layer design (see `kotlin-data-kmp-data-layer`)
+- Full architecture review (see `kotlin-project-architecture-review`)
+- Full feature implementation (see `kotlin-project-feature-implementation`)
+
+---
+
+## The state-holder contract
+
+Regardless of which pattern a project uses, a valid state holder must satisfy this contract:
+
+1. **One observable state output** — a single stream of immutable `UiState` that the UI renders from. Not several scattered booleans, not a mutable object the UI reads fields from directly.
+2. **Separate one-time effects** — navigation triggers, snackbar requests, permission launches, and similar one-shot actions must not be modeled as persistent state. Once consumed, they must not replay.
+3. **User actions as inputs** — the UI sends events or calls action functions; it does not set state directly or coordinate work.
+4. **No business rules in rendering** — the state holder processes inputs, coordinates lower layers, and produces output. Composables render.
+5. **Platform lifecycle transparency** — the state holder must behave correctly across the platform lifecycle events relevant to its target (configuration changes on Android, view disappear/appear on iOS, window focus on desktop).
+
+Every pattern described below is evaluated against this contract.
+
+---
+
+## Pattern families
+
+### 1. Android ViewModel with StateFlow (Android-only or Android-primary)
+
+**What it is:**  
+`ViewModel` from AndroidX lifecycle, holding a `MutableStateFlow<UiState>` privately and exposing it as `StateFlow<UiState>`. Effects emitted via a separate `SharedFlow` or `Channel`-backed flow.
+
+**When it is the right choice:**
+- The project targets Android only, or Android plus other platforms where ViewModel wrapper libraries are used
+- The team is already fluent with ViewModel semantics
+- Jetpack Compose with `collectAsStateWithLifecycle()` or `collectAsState()` is the rendering layer
+
+**What it provides:**
+- Automatic scoping to Android's ViewModel lifecycle (survives configuration changes by default via `viewModelScope`)
+- Strong Jetpack integration (`hiltViewModel()`, Navigation ViewModel scoping, `SavedStateHandle`)
+- Well-understood by Android developers
+
+**Platform constraint:**
+ViewModel is an AndroidX library. It does not exist natively on iOS, desktop, or web. Projects using ViewModel in shared `commonMain` code require an additional library to provide a ViewModel-compatible abstraction on non-Android targets (see KMP ViewModel libraries below).
+
+**Source-set placement:**
+- Pure Android ViewModel implementations belong in `androidMain`
+- If the project uses a KMP ViewModel abstraction, the shared interface can live in `commonMain`
+
+**Testability:**
+- Good: can be tested on JVM with `kotlinx-coroutines-test`, `TestScope`, `runTest`, and `Turbine` or `collectValues()`
+- `viewModelScope` must be replaced with an injected `CoroutineScope` in tests, or use the `ViewModelScenario`/rule pattern from `lifecycle-viewmodel-testing`
+
+---
+
+### 2. Shared presenter / state machine in commonMain
+
+**What it is:**  
+A plain Kotlin class in `commonMain` that holds a `MutableStateFlow<UiState>` and exposes it, processes user actions via functions or a sealed `Action` type, and emits effects. No AndroidX dependency. Lifecycle management is the platform entry point's responsibility.
+
+**When it is the right choice:**
+- The project targets multiple platforms and wants state-holder logic to be shared and tested once
+- The team wants to avoid a dependency on AndroidX ViewModel in shared code
+- The shared Kotlin logic is already substantial enough that platform-specific state holders would duplicate significant logic
+
+**What it provides:**
+- Fully testable in `commonTest` with `kotlin.test` and `kotlinx-coroutines-test`
+- No platform dependency — compiles for all KMP targets
+- Explicit lifecycle management (no magic — the platform entry point starts and cancels the scope)
+
+**Platform responsibility:**
+The presenter owns no lifecycle. Each platform must:
+- Create the presenter at the right point in the view lifecycle
+- Provide a `CoroutineScope` that is cancelled when the view is gone
+- Cancel the scope correctly on back navigation or view destruction
+- Handle process death / state restoration separately if needed (there is no `SavedStateHandle` equivalent without explicit implementation)
+
+On Android, this usually means creating the presenter inside a ViewModel to retain it across configuration changes, then delegating to it. On iOS, this means creating the presenter in the view owner (e.g., `ObservableObject`-wrapping in SwiftUI or direct state subscription in UIKit) and tying its scope to the view's lifetime.
+
+**Source-set placement:**
+- Presenter class: `commonMain`
+- Platform lifecycle wiring: `androidMain`, `iosMain`, etc.
+- Tests: `commonTest`
+
+**Testability:**
+- Excellent: all state-transition logic testable in `commonTest` with no Android dependency
+- Coroutine scope injection makes deterministic testing straightforward
+
+---
+
+### 3. KMP ViewModel abstraction libraries
+
+**What it is:**  
+Third-party or JetBrains-supported libraries that provide a `ViewModel` class in `commonMain` that compiles to AndroidX ViewModel on Android and to a lifecycle-aware equivalent on other targets.
+
+As of mid-2025, the main options are:
+- **`lifecycle-viewmodel` KMP artifact** (from AndroidX/JetBrains): JetBrains introduced official KMP support for `androidx.lifecycle.ViewModel` as a multiplatform artifact. This is the most official path when it matches the project's target set.
+- **Third-party ViewModel abstractions**: several community libraries provide similar functionality; evaluate for stability, maintenance, and target support before adopting.
+
+**When it is the right choice:**
+- The project wants to write ViewModel-style code once in `commonMain` and have it behave correctly on all targets including Android
+- The team does not want to manage lifecycle wiring manually per platform
+- The lifecycle-viewmodel KMP artifact covers the project's targets
+
+**What it provides:**
+- Shared ViewModel in `commonMain` with lifecycle-correct behavior per platform
+- `viewModelScope` (or equivalent) provided per-platform
+- Familiar ViewModel API surface
+
+**What to verify:**
+- Target coverage: confirm the chosen artifact supports all declared KMP targets
+- API stability: check current release status — KMP ViewModel support matured significantly in 2024–2025 but verify against current AndroidX release notes
+- `SavedStateHandle` availability: may not be supported on non-Android targets; check per-library docs
+
+**Source-set placement:**
+- ViewModel classes: `commonMain`
+- Platform-specific lifecycle wiring (if any): platform source sets
+
+**Testability:**
+- Similar to shared presenter: use injected scopes and `kotlinx-coroutines-test` in `commonTest`
+- Avoid testing through `AndroidX` test infrastructure if the goal is shared test coverage
+
+---
+
+### 4. MVI (Model-View-Intent) pattern
+
+**What it is:**  
+A stricter unidirectional architecture where user actions are explicitly typed as `Intent` or `Action` objects, the state holder reduces them into new `State` objects (often via a pure function or a reducer), and side effects are modeled explicitly as an `Effect` or `SideEffect` type.
+
+MVI is a pattern, not a library. It can be implemented on top of any of the above state-holder mechanisms. Several community libraries (Orbit MVI, MVI Kotlin, etc.) provide MVI structure as a framework.
+
+**When it is the right choice:**
+- Screens have complex, non-trivial state transitions where the action → state relationship benefits from being an explicit, traceable function
+- The team wants stricter discipline on what can cause a state change
+- Testing with explicit action/state pairs (action `X` in current state `S` produces state `S'`) is valuable for the use case
+
+**When it is likely overkill:**
+- Simple screens with few states (loading/success/error) — the overhead of action types and reducers exceeds the complexity
+- Small teams or fast-moving projects where ceremony slows iteration more than it helps
+
+**What it provides:**
+- Highly deterministic state transitions
+- Explicit, auditable action log
+- Effects (one-time events) are a first-class concern in most MVI implementations
+- Straightforward to test at the reducer level with pure functions
+
+**What to watch:**
+- Action type proliferation: large screens can accumulate dozens of action subtypes, making the sealed class unwieldy
+- Effect vs state confusion still occurs even with MVI — a one-time navigation trigger modeled as persistent state is wrong regardless of the pattern name
+- Library choice matters for KMP: verify the chosen MVI library supports your full target set
+
+**Source-set placement:**
+- If using a KMP-compatible MVI library: state holder and reducer in `commonMain`
+- Platform wiring as needed in platform source sets
+
+---
+
+## Effect handling
+
+One-time effects are the most common source of state-management bugs. The core mistake is modeling an effect as persistent state.
+
+### The problem
+
+```kotlin
+// WRONG: snackbar as persistent state
+data class UiState(
+    val items: List<Item>,
+    val errorMessage: String?  // stays in state after being shown — shows again on recomposition
+)
+```
+
+A user sees the snackbar. They rotate the screen. The state is re-collected. The snackbar shows again. The error message is now part of permanent UI state and will survive any state restoration.
+
+### Correct approaches
+
+**Option A: Separate `SharedFlow` for effects**
+```kotlin
+// State holds only persistent UI truth
+data class UiState(val items: List<Item>, val isLoading: Boolean)
+
+// Effects are fire-and-forget
+sealed interface UiEffect {
+    data class ShowError(val message: String) : UiEffect
+    data object NavigateToDetail : UiEffect
+}
+
+val uiState: StateFlow<UiState>
+val effects: SharedFlow<UiEffect>  // replay = 0
+```
+
+The `SharedFlow` with `replay = 0` emits once to current subscribers. No replay on new subscription. UI collects this in a `LaunchedEffect` keyed to the composable lifecycle.
+
+**Option B: Nullable one-time event in state with explicit consumption**  
+Some teams model effects as nullable fields with an explicit "consumed" action. This works but requires discipline — forgetting to send the consumed action is a common mistake.
+
+**Option C: `Channel`-backed flow (FIFO queue)**  
+A `Channel(BUFFERED)` exposed as a `receiveAsFlow()` delivers effects one at a time to one subscriber. Good for effects that must not be dropped even during lifecycle transitions, but adds buffering complexity.
+
+**Review rule:** Every non-null, non-boolean field in `UiState` that represents an action rather than a fact is likely a misplaced effect. Ask: "If this screen is recreated, should this still be shown?" If no, it is an effect.
+
+---
+
+## UiState modeling
+
+### Shape `UiState` around screen truth, not data-layer truth
+
+```kotlin
+// WRONG: mirrors the repository response
+data class UiState(
+    val user: User?,
+    val posts: List<Post>?,
+    val isLoadingUser: Boolean,
+    val isLoadingPosts: Boolean,
+    val userError: Throwable?,
+    val postsError: Throwable?
+)
+// 64 incoherent combinations
+
+// BETTER: models screen reality
+sealed interface UiState {
+    data object Loading : UiState
+    data class Success(val user: User, val posts: List<Post>) : UiState
+    data class Error(val reason: ErrorReason, val canRetry: Boolean) : UiState
+    data class PartialContent(val user: User, val postsError: String) : UiState
+}
+```
+
+### Rules
+
+- Immutable: `data class` with `val` fields; expose as interface or sealed type when substate variation exists
+- No raw `Throwable` exposed to UI — map to a presentation error type at the state-holder boundary
+- No DTOs, persistence models, or network response types in `UiState`
+- Include all states the UI can actually be in: loading, success, empty (distinct from loading), error, partial, retry-available
+- Avoid boolean flag explosion: three booleans produce 8 states; most are incoherent in practice
+
+---
+
+## Platform-specific lifecycle differences
+
+| Platform | Config change behavior | State restoration | Scope owner |
+|---|---|---|---|
+| Android (ViewModel) | Survives by default | `SavedStateHandle` | `viewModelScope` |
+| Android (shared presenter in ViewModel) | Survives if hosted in ViewModel | Manual or `SavedStateHandle` via wrapper | ViewModel-provided scope |
+| iOS (SwiftUI ObservableObject) | N/A — no config changes | Manual (`@AppStorage`, custom) | View owner; must cancel on deinit |
+| iOS (UIKit VC) | N/A | Manual | VC; must cancel in `viewDidDisappear`/`deinit` |
+| Desktop (Compose Desktop) | Window resize does not recreate | Manual | Root composable or explicit scope |
+| Web (Compose Web/Wasm) | N/A | Manual or URL-driven | Entry point |
+
+### Review implications
+
+- On Android, a shared presenter not hosted inside a ViewModel will be destroyed on rotation and recreate its state from scratch — this is usually wrong for screens with significant load cost
+- On iOS, a presenter scope that is not cancelled on view disappearance leaks the coroutine indefinitely
+- Do not assume Android configuration-change semantics apply to other targets
+- Do not assume iOS memory-pressure behavior matches Android process death
+
+---
+
+## Review dimensions
+
+### 1. Contract satisfaction
+
+Check whether the state-holder satisfies the full contract: one observable state output, separate effects, user actions as inputs, no business rules in rendering, lifecycle correctness.
+
+Flag as a concern when:
+- Multiple state streams are exposed for the same screen
+- Effects are modeled as persistent state fields
+- Business logic runs in composables
+- State is mutated from outside the state holder
+
+### 2. Pattern appropriateness
+
+Check whether the chosen pattern matches the project's actual target set and team context.
+
+Flag as a concern when:
+- AndroidX ViewModel is used in `commonMain` without a KMP ViewModel abstraction layer
+- A shared presenter is used on Android without a ViewModel wrapper, causing loss on config changes
+- MVI is applied to simple screens where it adds ceremony without clarity
+- The pattern is inconsistent across similar screens without a stated reason
+
+### 3. Effect handling correctness
+
+Check whether one-time effects are modeled distinctly from persistent `UiState`.
+
+Flag as a concern when:
+- Effects are modeled as nullable or boolean fields in `UiState`
+- Effects replay on recomposition or lifecycle re-entry
+- Navigation triggers are part of persistent screen state
+
+### 4. UiState shape
+
+Check whether `UiState` correctly models the states the screen can actually be in.
+
+Flag as a concern when:
+- Boolean flag combinations produce incoherent states
+- Raw `Throwable` or error strings are exposed directly
+- DTOs or data-layer models are present in `UiState`
+- Empty, partial-data, and retry states are absent despite the feature needing them
+
+### 5. Source-set placement
+
+Check whether state-holder code is placed correctly for its actual dependencies.
+
+Flag as a concern when:
+- AndroidX ViewModel is imported in `commonMain` without a KMP abstraction
+- Platform lifecycle types (Activity, UIViewController) appear in shared state-holder logic
+- A `commonMain` state holder uses `android.os.Bundle` or other Android-specific types
+
+### 6. Scope and lifecycle management
+
+Check whether the coroutine scope the state holder uses is managed correctly for each platform.
+
+Flag as a concern when:
+- Scopes are not cancelled when the view is destroyed on any target
+- Android presenters survive configuration changes unintentionally (or fail to survive them when they should)
+- The scope owner is unclear or inconsistent
+
+### 7. Testability
+
+Check whether state-holder logic is testable at the unit level.
+
+Flag as a concern when:
+- State transitions can only be validated through UI tests
+- The scope is not injectable, making deterministic testing hard
+- Effects cannot be asserted without running the full UI
+- The state holder has hidden dependencies on platform singletons
+
+---
+
+## Severity framework
+
+### High severity
+
+- Effects modeled as persistent state (causes replay bugs)
+- AndroidX ViewModel in `commonMain` without KMP abstraction (build fails on non-Android targets)
+- Presenter scope not cancelled on view destruction (coroutine leak)
+- State mutated from outside the state holder (breaks UDF)
+- Business logic in composables with no state-holder boundary
+
+### Medium severity
+
+- Android shared presenter not hosted in ViewModel (loses state on config change)
+- Inconsistent pattern across similar screens without reason
+- Boolean flag explosion in `UiState`
+- DTOs present in `UiState`
+- State transitions only testable through UI or instrumented tests
+
+### Low severity
+
+- State holder growing large but still correct (consider splitting the screen or extracting domain logic)
+- MVI applied to a simple screen (ceremonial but not broken)
+- Effect mechanism works but could be clearer
+
+---
+
+## Required output format
+
+When reviewing or designing state management, respond with:
+
+1. **State management summary**
+   - pattern in use (ViewModel, shared presenter, KMP ViewModel library, MVI)
+   - source-set placement
+   - effect handling mechanism
+   - `UiState` shape
+   - scope/lifecycle owner per platform
+
+2. **What is structurally sound**
+   - concrete strengths only
+
+3. **Issues by dimension**
+   - contract satisfaction
+   - pattern appropriateness
+   - effect handling
+   - `UiState` shape
+   - source-set placement
+   - scope/lifecycle management
+   - testability
+
+4. **Severity for each issue**
+   - high / medium / low
+
+5. **Concrete recommendations**
+   - exact changes to effect handling
+   - `UiState` restructuring
+   - scope management fixes
+   - source-set corrections
+   - pattern migration steps if needed
+
+6. **Suggested target structure**
+   - proposed state holder / effect / UiState split if useful
+
+7. **Open risks**
+   - platform-specific lifecycle edge cases still to validate
+   - migration cost
+   - KMP ViewModel library stability caveats if relevant
+
+---
+
+## Anti-patterns to flag aggressively
+
+- Effects modeled as nullable or boolean fields in `UiState`
+- `MutableStateFlow` exposed as public API from a state holder
+- Business logic in composable rendering functions
+- Multiple independent state streams for the same screen
+- AndroidX ViewModel imported in `commonMain` without a KMP abstraction
+- Presenter scope leaked on iOS or desktop (not cancelled on view destruction)
+- Raw `Throwable` or DTO types in `UiState`
+- State transitions that can only be verified through full UI tests
+- Inconsistent pattern choice across similar screens without a documented reason
+
+---
+
+## References
+
+- Android Developers: UI layer — https://developer.android.com/topic/architecture/ui-layer
+- Android Developers: UI events — https://developer.android.com/topic/architecture/ui-layer/events
+- Android Developers: State holders and UI state — https://developer.android.com/topic/architecture/ui-layer/stateholders
+- Android Developers: ViewModel overview — https://developer.android.com/topic/libraries/architecture/viewmodel
+- AndroidX Lifecycle KMP (ViewModel multiplatform) — https://developer.android.com/jetpack/androidx/releases/lifecycle
+- Kotlin coroutines guide — https://kotlinlang.org/docs/coroutines-guide.html
+- kotlinx-coroutines-test — https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/
+- Compose Multiplatform state and side effects — https://kotlinlang.org/docs/multiplatform/compose-multiplatform.html
+- kotlin.test — https://kotlinlang.org/api/latest/kotlin.test/

+ 149 - 0
.claude/skills/kotlin-specialist/SKILL.md

@@ -0,0 +1,149 @@
+---
+name: kotlin-specialist
+description: Provides idiomatic Kotlin implementation patterns including coroutine concurrency, Flow stream handling, multiplatform architecture, Compose UI construction, Ktor server setup, and type-safe DSL design. Use when building Kotlin applications requiring coroutines, multiplatform development, or Android with Compose. Invoke for Flow API, KMP projects, Ktor servers, DSL design, sealed classes, suspend function, Android Kotlin, Kotlin Multiplatform.
+license: MIT
+metadata:
+  author: https://github.com/Jeffallan
+  version: "1.1.0"
+  domain: language
+  triggers: Kotlin, coroutines, Kotlin Multiplatform, KMP, Jetpack Compose, Ktor, Flow, Android Kotlin, suspend function
+  role: specialist
+  scope: implementation
+  output-format: code
+  related-skills: test-master
+---
+
+# Kotlin Specialist
+
+Senior Kotlin developer with deep expertise in coroutines, Kotlin Multiplatform (KMP), and modern Kotlin 1.9+ patterns.
+
+## Core Workflow
+
+1. **Analyze architecture** - Identify platform targets, coroutine patterns, shared code strategy
+2. **Design models** - Create sealed classes, data classes, type hierarchies
+3. **Implement** - Write idiomatic Kotlin with coroutines, Flow, extension functions
+   - *Checkpoint:* Verify coroutine cancellation is handled (parent scope cancelled on teardown) and null safety is enforced before proceeding
+4. **Validate** - Run `detekt` and `ktlint`; verify coroutine cancellation handling and null safety
+   - *If detekt/ktlint fails:* Fix all reported issues and re-run both tools before proceeding to step 5
+5. **Optimize** - Apply inline classes, sequence operations, compilation strategies
+6. **Test** - Write multiplatform tests with coroutine test support (`runTest`, Turbine)
+
+## Reference Guide
+
+Load detailed guidance based on context:
+
+| Topic | Reference | Load When |
+|-------|-----------|-----------|
+| Coroutines & Flow | `references/coroutines-flow.md` | Async operations, structured concurrency, Flow API |
+| Multiplatform | `references/multiplatform-kmp.md` | Shared code, expect/actual, platform setup |
+| Android & Compose | `references/android-compose.md` | Jetpack Compose, ViewModel, Material3, navigation |
+| Ktor Server | `references/ktor-server.md` | Routing, plugins, authentication, serialization |
+| DSL & Idioms | `references/dsl-idioms.md` | Type-safe builders, scope functions, delegates |
+
+## Key Patterns
+
+### Sealed Classes for State Modeling
+
+```kotlin
+sealed class UiState<out T> {
+    data object Loading : UiState<Nothing>()
+    data class Success<T>(val data: T) : UiState<T>()
+    data class Error(val message: String, val cause: Throwable? = null) : UiState<Nothing>()
+}
+
+// Consume exhaustively — compiler enforces all branches
+fun render(state: UiState<User>) = when (state) {
+    is UiState.Loading  -> showSpinner()
+    is UiState.Success  -> showUser(state.data)
+    is UiState.Error    -> showError(state.message)
+}
+```
+
+### Coroutines & Flow
+
+```kotlin
+// Use structured concurrency — never GlobalScope
+class UserRepository(private val api: UserApi, private val scope: CoroutineScope) {
+
+    fun userUpdates(id: String): Flow<UiState<User>> = flow {
+        emit(UiState.Loading)
+        try {
+            emit(UiState.Success(api.fetchUser(id)))
+        } catch (e: IOException) {
+            emit(UiState.Error("Network error", e))
+        }
+    }.flowOn(Dispatchers.IO)
+
+    private val _user = MutableStateFlow<UiState<User>>(UiState.Loading)
+    val user: StateFlow<UiState<User>> = _user.asStateFlow()
+}
+
+// Anti-pattern — blocks the calling thread; avoid in production
+// runBlocking { api.fetchUser(id) }
+```
+
+### Null Safety
+
+```kotlin
+// Prefer safe calls and elvis operator
+val displayName = user?.profile?.name ?: "Anonymous"
+
+// Use let to scope nullable operations
+user?.email?.let { email -> sendNotification(email) }
+
+// !! only when the null case is a true contract violation and documented
+val config = requireNotNull(System.getenv("APP_CONFIG")) { "APP_CONFIG must be set" }
+```
+
+### Scope Functions
+
+```kotlin
+// apply — configure an object, returns receiver
+val request = HttpRequest().apply {
+    url = "https://api.example.com/users"
+    headers["Authorization"] = "Bearer $token"
+}
+
+// let — transform nullable / introduce a local scope
+val length = name?.let { it.trim().length } ?: 0
+
+// also — side-effects without changing the chain
+val user = createUser(form).also { logger.info("Created user ${it.id}") }
+```
+
+## Constraints
+
+### MUST DO
+- Use null safety (`?`, `?.`, `?:`, `!!` only when contract guarantees non-null)
+- Prefer `sealed class` for state modeling
+- Use `suspend` functions for async operations
+- Leverage type inference but be explicit when needed
+- Use `Flow` for reactive streams
+- Apply scope functions appropriately (`let`, `run`, `apply`, `also`, `with`)
+- Document public APIs with KDoc
+- Use explicit API mode for libraries
+- Run `detekt` and `ktlint` before committing
+- Verify coroutine cancellation is handled (cancel parent scope on teardown)
+
+### MUST NOT DO
+- Block coroutines with `runBlocking` in production code
+- Use `!!` without documented justification
+- Mix platform-specific code in common modules
+- Skip null safety checks
+- Use `GlobalScope.launch` (use structured concurrency)
+- Ignore coroutine cancellation
+- Create memory leaks with coroutine scopes
+
+## Output Templates
+
+When implementing Kotlin features, provide:
+1. Data models (sealed classes, data classes)
+2. Implementation file (extension functions, suspend functions)
+3. Test file with coroutine test support
+4. Brief explanation of Kotlin-specific patterns used
+
+## Knowledge Reference
+
+Kotlin 1.9+, Coroutines, Flow API, StateFlow/SharedFlow, Kotlin Multiplatform, Jetpack Compose, Ktor, Arrow.kt, kotlinx.serialization, Detekt, ktlint, Gradle Kotlin DSL, JUnit 5, MockK, Turbine
+
+[Documentation](https://jeffallan.github.io/claude-skills/skills/language/kotlin-specialist/)

+ 419 - 0
.claude/skills/kotlin-specialist/references/android-compose.md

@@ -0,0 +1,419 @@
+# Android & Jetpack Compose
+
+## Compose Basics
+
+```kotlin
+import androidx.compose.runtime.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun UserProfile(user: User, onEdit: () -> Unit) {
+    Card(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(16.dp)
+    ) {
+        Column(modifier = Modifier.padding(16.dp)) {
+            Text(
+                text = user.name,
+                style = MaterialTheme.typography.headlineMedium
+            )
+            Text(
+                text = user.email,
+                style = MaterialTheme.typography.bodyMedium,
+                color = MaterialTheme.colorScheme.onSurfaceVariant
+            )
+            Spacer(modifier = Modifier.height(8.dp))
+            Button(onClick = onEdit) {
+                Text("Edit Profile")
+            }
+        }
+    }
+}
+```
+
+## State Management
+
+```kotlin
+// ViewModel with StateFlow
+class UserViewModel(
+    private val repository: UserRepository
+) : ViewModel() {
+    private val _uiState = MutableStateFlow(UserUiState())
+    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
+
+    fun loadUser(userId: String) {
+        viewModelScope.launch {
+            _uiState.update { it.copy(isLoading = true) }
+            try {
+                val user = repository.getUser(userId)
+                _uiState.update { it.copy(user = user, isLoading = false) }
+            } catch (e: Exception) {
+                _uiState.update { it.copy(error = e.message, isLoading = false) }
+            }
+        }
+    }
+}
+
+data class UserUiState(
+    val user: User? = null,
+    val isLoading: Boolean = false,
+    val error: String? = null
+)
+
+// Composable using ViewModel
+@Composable
+fun UserScreen(
+    viewModel: UserViewModel = hiltViewModel(),
+    userId: String
+) {
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+    LaunchedEffect(userId) {
+        viewModel.loadUser(userId)
+    }
+
+    when {
+        uiState.isLoading -> LoadingIndicator()
+        uiState.error != null -> ErrorMessage(uiState.error!!)
+        uiState.user != null -> UserProfile(uiState.user!!)
+    }
+}
+```
+
+## Material 3 Theme
+
+```kotlin
+@Composable
+fun AppTheme(
+    darkTheme: Boolean = isSystemInDarkTheme(),
+    content: @Composable () -> Unit
+) {
+    val colorScheme = if (darkTheme) {
+        darkColorScheme(
+            primary = Purple80,
+            secondary = PurpleGrey80,
+            tertiary = Pink80
+        )
+    } else {
+        lightColorScheme(
+            primary = Purple40,
+            secondary = PurpleGrey40,
+            tertiary = Pink40
+        )
+    }
+
+    MaterialTheme(
+        colorScheme = colorScheme,
+        typography = Typography,
+        content = content
+    )
+}
+```
+
+## Navigation
+
+```kotlin
+import androidx.navigation.compose.*
+
+@Composable
+fun AppNavigation() {
+    val navController = rememberNavController()
+
+    NavHost(
+        navController = navController,
+        startDestination = "home"
+    ) {
+        composable("home") {
+            HomeScreen(
+                onNavigateToProfile = { userId ->
+                    navController.navigate("profile/$userId")
+                }
+            )
+        }
+
+        composable(
+            route = "profile/{userId}",
+            arguments = listOf(navArgument("userId") { type = NavType.StringType })
+        ) { backStackEntry ->
+            val userId = backStackEntry.arguments?.getString("userId")
+            ProfileScreen(
+                userId = userId ?: "",
+                onBack = { navController.popBackStack() }
+            )
+        }
+
+        composable("settings") {
+            SettingsScreen()
+        }
+    }
+}
+```
+
+## LazyColumn (Lists)
+
+```kotlin
+@Composable
+fun UserList(
+    users: List<User>,
+    onUserClick: (User) -> Unit
+) {
+    LazyColumn(
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = PaddingValues(16.dp),
+        verticalArrangement = Arrangement.spacedBy(8.dp)
+    ) {
+        items(users, key = { it.id }) { user ->
+            UserCard(
+                user = user,
+                onClick = { onUserClick(user) }
+            )
+        }
+    }
+}
+
+// Pagination with LazyColumn
+@Composable
+fun PaginatedList(viewModel: ListViewModel = hiltViewModel()) {
+    val items by viewModel.items.collectAsStateWithLifecycle()
+    val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
+
+    LazyColumn {
+        items(items, key = { it.id }) { item ->
+            ItemCard(item)
+        }
+
+        if (isLoading) {
+            item {
+                CircularProgressIndicator(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(16.dp)
+                )
+            }
+        }
+
+        // Load more trigger
+        item {
+            LaunchedEffect(Unit) {
+                viewModel.loadMore()
+            }
+        }
+    }
+}
+```
+
+## Side Effects
+
+```kotlin
+@Composable
+fun UserScreen(userId: String) {
+    // Run once when userId changes
+    LaunchedEffect(userId) {
+        loadUser(userId)
+    }
+
+    // Run on every recomposition
+    SideEffect {
+        analyticsService.trackScreen("UserScreen")
+    }
+
+    // Cleanup when leaving composition
+    DisposableEffect(Unit) {
+        val listener = setupListener()
+        onDispose {
+            listener.cleanup()
+        }
+    }
+
+    // Remember value across recompositions
+    val scrollState = rememberScrollState()
+
+    // Derived state
+    val isScrolled by remember {
+        derivedStateOf { scrollState.value > 0 }
+    }
+}
+```
+
+## Dependency Injection (Hilt)
+
+```kotlin
+// Application class
+@HiltAndroidApp
+class MyApplication : Application()
+
+// Module
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+    @Provides
+    @Singleton
+    fun provideApiService(): ApiService = ApiServiceImpl()
+
+    @Provides
+    @Singleton
+    fun provideUserRepository(api: ApiService): UserRepository =
+        UserRepositoryImpl(api)
+}
+
+// ViewModel with injection
+@HiltViewModel
+class UserViewModel @Inject constructor(
+    private val repository: UserRepository,
+    private val savedStateHandle: SavedStateHandle
+) : ViewModel() {
+    private val userId: String = savedStateHandle["userId"] ?: ""
+
+    val user: StateFlow<User?> = repository
+        .getUserFlow(userId)
+        .stateIn(
+            scope = viewModelScope,
+            started = SharingStarted.WhileSubscribed(5000),
+            initialValue = null
+        )
+}
+
+// Activity
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContent {
+            AppTheme {
+                AppNavigation()
+            }
+        }
+    }
+}
+```
+
+## Remember & State
+
+```kotlin
+@Composable
+fun SearchScreen() {
+    // State hoisting
+    var query by remember { mutableStateOf("") }
+    var results by remember { mutableStateOf<List<Result>>(emptyList()) }
+
+    Column {
+        SearchBar(
+            query = query,
+            onQueryChange = { query = it },
+            onSearch = {
+                // Trigger search
+            }
+        )
+
+        ResultsList(results)
+    }
+}
+
+// Remember with keys
+@Composable
+fun UserDetail(userId: String) {
+    val user = remember(userId) {
+        loadUser(userId)
+    }
+
+    // rememberSaveable survives process death
+    var expanded by rememberSaveable { mutableStateOf(false) }
+}
+```
+
+## Animation
+
+```kotlin
+import androidx.compose.animation.*
+import androidx.compose.animation.core.*
+
+@Composable
+fun AnimatedContent() {
+    var visible by remember { mutableStateOf(false) }
+
+    // Simple fade
+    AnimatedVisibility(visible) {
+        Text("Hello World")
+    }
+
+    // Custom animation
+    val alpha by animateFloatAsState(
+        targetValue = if (visible) 1f else 0f,
+        animationSpec = tween(durationMillis = 300)
+    )
+
+    // Animated content
+    AnimatedContent(
+        targetState = selectedTab,
+        transitionSpec = {
+            fadeIn() + slideInVertically() togetherWith
+                    fadeOut() + slideOutVertically()
+        }
+    ) { tab ->
+        when (tab) {
+            0 -> HomeContent()
+            1 -> ProfileContent()
+        }
+    }
+}
+```
+
+## Performance Optimization
+
+```kotlin
+// Stability annotations
+@Immutable
+data class User(val id: String, val name: String)
+
+@Stable
+class UserState(private val repository: UserRepository) {
+    val users: StateFlow<List<User>> = repository.users
+}
+
+// Key for recomposition optimization
+@Composable
+fun ItemList(items: List<Item>) {
+    LazyColumn {
+        items(items, key = { it.id }) { item ->
+            ItemCard(item)
+        }
+    }
+}
+
+// derivedStateOf for expensive calculations
+@Composable
+fun FilteredList(items: List<Item>, filter: String) {
+    val filtered by remember(items, filter) {
+        derivedStateOf {
+            items.filter { it.name.contains(filter, ignoreCase = true) }
+        }
+    }
+
+    LazyColumn {
+        items(filtered) { item ->
+            ItemCard(item)
+        }
+    }
+}
+```
+
+## Quick Reference
+
+| Composable | Purpose |
+|------------|---------|
+| `remember` | Retain value across recompositions |
+| `rememberSaveable` | Survive process death |
+| `LaunchedEffect` | Run suspend functions |
+| `DisposableEffect` | Cleanup when leaving |
+| `SideEffect` | Non-suspend effects |
+| `derivedStateOf` | Computed state |
+| `collectAsStateWithLifecycle` | Flow to State (lifecycle-aware) |
+| `animateFloatAsState` | Animate value changes |
+| `LazyColumn` | Scrollable list |
+| `Scaffold` | Material 3 layout structure |
+| `viewModelScope` | ViewModel coroutine scope |
+| `@HiltViewModel` | Hilt dependency injection |

+ 276 - 0
.claude/skills/kotlin-specialist/references/coroutines-flow.md

@@ -0,0 +1,276 @@
+# Coroutines & Flow API
+
+## Structured Concurrency
+
+```kotlin
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+
+class UserRepository(
+    private val api: ApiService,
+    private val scope: CoroutineScope
+) {
+    // CORRECT: Structured concurrency with supervisor
+    suspend fun fetchUsers(): Result<List<User>> = coroutineScope {
+        supervisorScope {
+            try {
+                val users = async { api.getUsers() }
+                val profiles = async { api.getProfiles() }
+                Result.success(users.await() + profiles.await())
+            } catch (e: Exception) {
+                Result.failure(e)
+            }
+        }
+    }
+
+    // WRONG: GlobalScope bypasses structured concurrency
+    // fun fetchUsersWrong() = GlobalScope.launch { ... }
+}
+```
+
+## Coroutine Scopes & Dispatchers
+
+```kotlin
+class ViewModel : CoroutineScope {
+    override val coroutineContext = SupervisorJob() + Dispatchers.Main
+
+    fun loadData() {
+        launch {
+            val data = withContext(Dispatchers.IO) {
+                // I/O operations on IO dispatcher
+                repository.fetchData()
+            }
+            // Back to Main dispatcher automatically
+            updateUI(data)
+        }
+    }
+
+    fun cleanup() {
+        coroutineContext.cancelChildren()
+    }
+}
+
+// Android ViewModel - use viewModelScope
+class AndroidViewModel : ViewModel() {
+    fun loadUsers() {
+        viewModelScope.launch {
+            userRepository.getUsers().collect { users ->
+                _uiState.update { it.copy(users = users) }
+            }
+        }
+    }
+}
+```
+
+## Flow Basics
+
+```kotlin
+// Cold flow - starts on collection
+fun getUsers(): Flow<List<User>> = flow {
+    val users = api.fetchUsers()
+    emit(users)
+    delay(1000)
+    emit(users + api.fetchNewUsers())
+}.flowOn(Dispatchers.IO)
+
+// Hot flow - StateFlow (always has value)
+class UserStore {
+    private val _users = MutableStateFlow<List<User>>(emptyList())
+    val users: StateFlow<List<User>> = _users.asStateFlow()
+
+    suspend fun loadUsers() {
+        api.getUsers().collect { userList ->
+            _users.update { userList }
+        }
+    }
+}
+
+// Hot flow - SharedFlow (events, no initial value)
+class EventBus {
+    private val _events = MutableSharedFlow<Event>(
+        replay = 0,
+        extraBufferCapacity = 10,
+        onBufferOverflow = BufferOverflow.DROP_OLDEST
+    )
+    val events: SharedFlow<Event> = _events.asSharedFlow()
+
+    suspend fun emit(event: Event) {
+        _events.emit(event)
+    }
+}
+```
+
+## Flow Operators
+
+```kotlin
+fun getUsersWithPosts(): Flow<UserWithPosts> = flow {
+    userRepository.getUsers()
+        .map { user -> UserWithPosts(user, getPosts(user.id)) }
+        .filter { it.posts.isNotEmpty() }
+        .catch { e -> emit(UserWithPosts.Error(e)) }
+        .onEach { delay(100) } // Throttle
+        .distinctUntilChanged()
+        .collect { emit(it) }
+}
+
+// Combining flows
+fun getCombinedData(): Flow<UiState> = combine(
+    userFlow,
+    settingsFlow,
+    notificationsFlow
+) { user, settings, notifications ->
+    UiState(user, settings, notifications)
+}
+
+// Flattening flows
+fun searchUsers(query: String): Flow<List<User>> =
+    queryFlow
+        .debounce(300)
+        .filter { it.length >= 3 }
+        .distinctUntilChanged()
+        .flatMapLatest { query ->
+            repository.search(query)
+        }
+```
+
+## Exception Handling
+
+```kotlin
+suspend fun loadDataSafely(): Result<Data> =
+    supervisorScope {
+        try {
+            val result = async {
+                api.getData()
+            }
+            Result.success(result.await())
+        } catch (e: CancellationException) {
+            // Don't catch cancellation - rethrow
+            throw e
+        } catch (e: Exception) {
+            Result.failure(e)
+        }
+    }
+
+// Flow error handling
+fun getDataFlow(): Flow<Data> = flow {
+    emit(api.getData())
+}.retry(3) { cause ->
+    cause is IOException
+}.catch { e ->
+    emit(Data.Error(e))
+}
+
+// Supervisor scope for independent children
+suspend fun loadMultiple() = supervisorScope {
+    val job1 = launch { task1() } // Failure won't affect job2
+    val job2 = launch { task2() }
+    joinAll(job1, job2)
+}
+```
+
+## Cancellation
+
+```kotlin
+suspend fun cancellableWork() {
+    withTimeout(5000) {
+        while (isActive) { // Check for cancellation
+            doWork()
+            yield() // Cooperation point
+        }
+    }
+}
+
+// Cleanup with finally
+suspend fun withCleanup() {
+    try {
+        longRunningTask()
+    } finally {
+        withContext(NonCancellable) {
+            cleanup() // Always runs even if cancelled
+        }
+    }
+}
+```
+
+## Testing Coroutines
+
+```kotlin
+import kotlinx.coroutines.test.*
+
+class UserViewModelTest {
+    @Test
+    fun testLoadUsers() = runTest {
+        val viewModel = UserViewModel(fakeRepository)
+
+        viewModel.loadUsers()
+        advanceUntilIdle() // Run all pending coroutines
+
+        assertEquals(expectedUsers, viewModel.users.value)
+    }
+
+    @Test
+    fun testFlow() = runTest {
+        val flow = repository.getUsersFlow()
+        val results = flow.take(3).toList()
+
+        assertEquals(3, results.size)
+    }
+
+    // Testing with Turbine
+    @Test
+    fun testFlowWithTurbine() = runTest {
+        repository.getUsersFlow().test {
+            assertEquals(Loading, awaitItem())
+            assertEquals(Success(users), awaitItem())
+            awaitComplete()
+        }
+    }
+}
+```
+
+## Performance Patterns
+
+```kotlin
+// Use sequence for lazy evaluation
+fun processLargeList(items: List<Item>): List<Result> =
+    items.asSequence()
+        .filter { it.isValid }
+        .map { transform(it) }
+        .take(100)
+        .toList() // Only processes first 100 valid items
+
+// Channel for producer-consumer
+fun produceNumbers() = produce {
+    repeat(10) {
+        send(it)
+        delay(100)
+    }
+}
+
+// Parallel processing with async
+suspend fun processInParallel(items: List<Item>): List<Result> =
+    coroutineScope {
+        items.map { item ->
+            async { process(item) }
+        }.awaitAll()
+    }
+```
+
+## Quick Reference
+
+| Pattern | Use Case |
+|---------|----------|
+| `launch` | Fire-and-forget coroutine |
+| `async/await` | Parallel computation with result |
+| `flow { }` | Cold stream of values |
+| `StateFlow` | Hot flow with current state |
+| `SharedFlow` | Hot flow for events |
+| `withContext` | Switch dispatcher |
+| `supervisorScope` | Independent child failures |
+| `coroutineScope` | All children must succeed |
+| `flowOn` | Change flow dispatcher |
+| `catch` | Handle flow errors |
+| `retry` | Retry on failure |
+| `debounce` | Rate limiting |
+| `distinctUntilChanged` | Skip duplicates |
+| `combine` | Merge multiple flows |

+ 421 - 0
.claude/skills/kotlin-specialist/references/dsl-idioms.md

@@ -0,0 +1,421 @@
+# DSL & Kotlin Idioms
+
+## Type-Safe Builders
+
+```kotlin
+// HTML DSL example
+class Tag(val name: String) {
+    val children = mutableListOf<Tag>()
+    val attributes = mutableMapOf<String, String>()
+
+    fun <T : Tag> initTag(tag: T, init: T.() -> Unit): T {
+        tag.init()
+        children.add(tag)
+        return tag
+    }
+
+    override fun toString(): String {
+        val attrs = attributes.entries.joinToString(" ") { "${it.key}=\"${it.value}\"" }
+        val content = children.joinToString("")
+        return "<$name${if (attrs.isNotEmpty()) " $attrs" else ""}>$content</$name>"
+    }
+}
+
+class HTML : Tag("html") {
+    fun head(init: Head.() -> Unit) = initTag(Head(), init)
+    fun body(init: Body.() -> Unit) = initTag(Body(), init)
+}
+
+class Head : Tag("head") {
+    fun title(init: Title.() -> Unit) = initTag(Title(), init)
+}
+
+class Title : Tag("title") {
+    operator fun String.unaryPlus() {
+        children.add(TextNode(this))
+    }
+}
+
+class Body : Tag("body") {
+    fun div(classes: String? = null, init: Div.() -> Unit) =
+        initTag(Div(), init).apply {
+            classes?.let { attributes["class"] = it }
+        }
+}
+
+class Div : Tag("div") {
+    fun p(init: P.() -> Unit) = initTag(P(), init)
+}
+
+class P : Tag("p") {
+    operator fun String.unaryPlus() {
+        children.add(TextNode(this))
+    }
+}
+
+class TextNode(private val text: String) : Tag("") {
+    override fun toString() = text
+}
+
+// Usage
+fun html(init: HTML.() -> Unit): HTML {
+    val html = HTML()
+    html.init()
+    return html
+}
+
+val page = html {
+    head {
+        title { +"My Page" }
+    }
+    body {
+        div("container") {
+            p { +"Hello, World!" }
+        }
+    }
+}
+```
+
+## Lambda with Receiver
+
+```kotlin
+// Configuration DSL
+class DatabaseConfig {
+    var host: String = "localhost"
+    var port: Int = 5432
+    var username: String = ""
+    var password: String = ""
+    var database: String = ""
+}
+
+fun database(config: DatabaseConfig.() -> Unit): DatabaseConfig {
+    return DatabaseConfig().apply(config)
+}
+
+// Usage
+val dbConfig = database {
+    host = "db.example.com"
+    port = 3306
+    username = "admin"
+    password = "secret"
+    database = "myapp"
+}
+
+// Builder pattern with type-safe DSL
+class User private constructor(
+    val id: String,
+    val name: String,
+    val email: String,
+    val age: Int?
+) {
+    class Builder {
+        var id: String = ""
+        var name: String = ""
+        var email: String = ""
+        var age: Int? = null
+
+        fun build(): User {
+            require(id.isNotBlank()) { "ID is required" }
+            require(name.isNotBlank()) { "Name is required" }
+            require(email.isNotBlank()) { "Email is required" }
+            return User(id, name, email, age)
+        }
+    }
+}
+
+fun user(init: User.Builder.() -> Unit): User =
+    User.Builder().apply(init).build()
+
+// Usage
+val user = user {
+    id = "123"
+    name = "John Doe"
+    email = "john@example.com"
+    age = 30
+}
+```
+
+## Scope Functions
+
+```kotlin
+// let - transform and null check
+val result = user?.let { u ->
+    "${u.name} (${u.email})"
+}
+
+// run - execute block and return result
+val greeting = run {
+    val name = getName()
+    val title = getTitle()
+    "$title $name"
+}
+
+// with - operate on object
+val message = with(user) {
+    "User: $name, Email: $email, Active: $isActive"
+}
+
+// apply - configure object
+val user = User().apply {
+    name = "John"
+    email = "john@example.com"
+    isActive = true
+}
+
+// also - side effects
+val saved = user
+    .also { logger.info("Saving user: ${it.name}") }
+    .also { validate(it) }
+    .also { repository.save(it) }
+
+// takeIf/takeUnless - conditional returns
+val adult = user.takeIf { it.age >= 18 }
+val minor = user.takeUnless { it.age >= 18 }
+```
+
+## Extension Functions
+
+```kotlin
+// String extensions
+fun String.isValidEmail(): Boolean =
+    matches(Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\$"))
+
+fun String.truncate(length: Int, ellipsis: String = "..."): String =
+    if (this.length <= length) this
+    else "${take(length - ellipsis.length)}$ellipsis"
+
+// Collection extensions
+fun <T> List<T>.second(): T = this[1]
+
+fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null
+
+inline fun <T> Iterable<T>.sumOf(selector: (T) -> Double): Double {
+    var sum = 0.0
+    for (element in this) {
+        sum += selector(element)
+    }
+    return sum
+}
+
+// Generic extensions
+inline fun <T> T.applyIf(condition: Boolean, block: T.() -> Unit): T =
+    if (condition) apply(block) else this
+
+// Usage
+val email = "user@example.com"
+    .applyIf(email.isValidEmail()) {
+        toLowerCase()
+    }
+```
+
+## Delegated Properties
+
+```kotlin
+import kotlin.properties.Delegates
+
+// Lazy initialization
+class Repository {
+    val database: Database by lazy {
+        Database.connect("jdbc:postgresql://localhost/db")
+    }
+}
+
+// Observable property
+class User {
+    var name: String by Delegates.observable("<not set>") { prop, old, new ->
+        println("${prop.name} changed from $old to $new")
+    }
+}
+
+// Vetoable property (can reject changes)
+class Account {
+    var balance: Double by Delegates.vetoable(0.0) { _, old, new ->
+        new >= 0 // Only allow non-negative balance
+    }
+}
+
+// Custom delegate
+class Preference<T>(private val key: String, private val default: T) {
+    operator fun getValue(thisRef: Any?, property: KProperty<*>): T =
+        preferences.get(key) as? T ?: default
+
+    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
+        preferences.set(key, value)
+    }
+}
+
+class Settings {
+    var theme: String by Preference("theme", "light")
+    var fontSize: Int by Preference("fontSize", 14)
+}
+
+// Map delegation
+class UserData(map: Map<String, Any?>) {
+    val name: String by map
+    val age: Int by map
+    val email: String by map
+}
+
+val userData = UserData(
+    mapOf(
+        "name" to "John",
+        "age" to 30,
+        "email" to "john@example.com"
+    )
+)
+```
+
+## Infix Functions
+
+```kotlin
+// Custom infix operators
+infix fun <T> T.shouldBe(expected: T) {
+    if (this != expected) {
+        throw AssertionError("Expected $expected but got $this")
+    }
+}
+
+infix fun String.matches(regex: Regex): Boolean =
+    this.matches(regex)
+
+// Usage
+val result = 2 + 2
+result shouldBe 4
+
+"test@example.com" matches Regex(".*@.*\\..*")
+
+// DSL with infix
+class Route(val path: String) {
+    infix fun to(handler: () -> Unit): RouteDefinition =
+        RouteDefinition(path, handler)
+}
+
+data class RouteDefinition(val path: String, val handler: () -> Unit)
+
+infix fun String.GET(handler: () -> Unit): RouteDefinition =
+    Route(this) to handler
+
+// Usage
+val route = "/users" GET { println("Get users") }
+```
+
+## Operator Overloading
+
+```kotlin
+data class Vector(val x: Double, val y: Double) {
+    operator fun plus(other: Vector) =
+        Vector(x + other.x, y + other.y)
+
+    operator fun minus(other: Vector) =
+        Vector(x - other.x, y - other.y)
+
+    operator fun times(scalar: Double) =
+        Vector(x * scalar, y * scalar)
+
+    operator fun unaryMinus() =
+        Vector(-x, -y)
+
+    operator fun get(index: Int): Double = when (index) {
+        0 -> x
+        1 -> y
+        else -> throw IndexOutOfBoundsException()
+    }
+}
+
+// Usage
+val v1 = Vector(1.0, 2.0)
+val v2 = Vector(3.0, 4.0)
+val v3 = v1 + v2
+val v4 = v1 * 2.0
+val x = v1[0]
+
+// Invoke operator
+class Greeter(private val greeting: String) {
+    operator fun invoke(name: String) = "$greeting, $name!"
+}
+
+val greet = Greeter("Hello")
+println(greet("World")) // Hello, World!
+```
+
+## Sealed Classes & When
+
+```kotlin
+sealed class Result<out T> {
+    data class Success<T>(val data: T) : Result<T>()
+    data class Error(val exception: Exception) : Result<Nothing>()
+    object Loading : Result<Nothing>()
+}
+
+// Exhaustive when
+fun <T> handleResult(result: Result<T>): String = when (result) {
+    is Result.Success -> "Data: ${result.data}"
+    is Result.Error -> "Error: ${result.exception.message}"
+    Result.Loading -> "Loading..."
+}
+
+// Sealed interface for more flexibility
+sealed interface UiState {
+    object Loading : UiState
+    data class Success(val data: List<String>) : UiState
+    data class Error(val message: String) : UiState
+}
+```
+
+## Inline & Reified
+
+```kotlin
+// Inline function
+inline fun <T> measureTime(block: () -> T): Pair<T, Long> {
+    val start = System.currentTimeMillis()
+    val result = block()
+    val duration = System.currentTimeMillis() - start
+    return result to duration
+}
+
+// Reified type parameters
+inline fun <reified T> parseJson(json: String): T =
+    Json.decodeFromString<T>(json)
+
+inline fun <reified T : Any> Intent.getParcelableExtraCompat(key: String): T? =
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+        getParcelableExtra(key, T::class.java)
+    } else {
+        @Suppress("DEPRECATION")
+        getParcelableExtra(key) as? T
+    }
+
+// Value class (inline class)
+@JvmInline
+value class UserId(val value: String)
+
+@JvmInline
+value class Email(val value: String) {
+    init {
+        require(value.contains("@")) { "Invalid email" }
+    }
+}
+
+// Usage - zero runtime overhead
+val userId = UserId("123")
+val email = Email("test@example.com")
+```
+
+## Quick Reference
+
+| Idiom | Purpose |
+|-------|---------|
+| `let` | Transform & null check |
+| `run` | Execute block, return result |
+| `with` | Operate on object |
+| `apply` | Configure object |
+| `also` | Side effects |
+| `takeIf/takeUnless` | Conditional return |
+| `by lazy` | Lazy initialization |
+| `by Delegates.observable` | Observe changes |
+| `inline fun` | Eliminate lambda overhead |
+| `reified` | Access type at runtime |
+| `@JvmInline` | Zero-cost wrapper |
+| `infix` | Custom operators |
+| `operator` | Operator overloading |
+| `sealed class` | Restricted hierarchies |

+ 426 - 0
.claude/skills/kotlin-specialist/references/ktor-server.md

@@ -0,0 +1,426 @@
+# Ktor Server
+
+## Application Setup
+
+```kotlin
+import io.ktor.server.application.*
+import io.ktor.server.engine.*
+import io.ktor.server.netty.*
+import io.ktor.server.plugins.contentnegotiation.*
+import io.ktor.serialization.kotlinx.json.*
+import kotlinx.serialization.json.Json
+
+fun main() {
+    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
+        configureRouting()
+        configureSerialization()
+        configureAuth()
+        configureMonitoring()
+    }.start(wait = true)
+}
+
+fun Application.configureSerialization() {
+    install(ContentNegotiation) {
+        json(Json {
+            prettyPrint = true
+            isLenient = true
+            ignoreUnknownKeys = true
+        })
+    }
+}
+```
+
+## Routing
+
+```kotlin
+import io.ktor.server.routing.*
+import io.ktor.server.response.*
+import io.ktor.server.request.*
+import io.ktor.http.*
+
+fun Application.configureRouting() {
+    routing {
+        route("/api/v1") {
+            userRoutes()
+            postRoutes()
+        }
+    }
+}
+
+fun Route.userRoutes() {
+    route("/users") {
+        get {
+            val users = userService.getAllUsers()
+            call.respond(HttpStatusCode.OK, users)
+        }
+
+        get("/{id}") {
+            val id = call.parameters["id"]
+                ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing ID")
+
+            val user = userService.getUser(id)
+                ?: return@get call.respond(HttpStatusCode.NotFound, "User not found")
+
+            call.respond(HttpStatusCode.OK, user)
+        }
+
+        post {
+            val userRequest = call.receive<CreateUserRequest>()
+            val user = userService.createUser(userRequest)
+            call.respond(HttpStatusCode.Created, user)
+        }
+
+        put("/{id}") {
+            val id = call.parameters["id"]
+                ?: return@put call.respond(HttpStatusCode.BadRequest, "Missing ID")
+
+            val updateRequest = call.receive<UpdateUserRequest>()
+            val user = userService.updateUser(id, updateRequest)
+                ?: return@put call.respond(HttpStatusCode.NotFound, "User not found")
+
+            call.respond(HttpStatusCode.OK, user)
+        }
+
+        delete("/{id}") {
+            val id = call.parameters["id"]
+                ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing ID")
+
+            val deleted = userService.deleteUser(id)
+            if (deleted) {
+                call.respond(HttpStatusCode.NoContent)
+            } else {
+                call.respond(HttpStatusCode.NotFound, "User not found")
+            }
+        }
+    }
+}
+```
+
+## Models & Serialization
+
+```kotlin
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class User(
+    val id: String,
+    val email: String,
+    val name: String,
+    val createdAt: Long
+)
+
+@Serializable
+data class CreateUserRequest(
+    val email: String,
+    val name: String,
+    val password: String
+)
+
+@Serializable
+data class UpdateUserRequest(
+    val email: String? = null,
+    val name: String? = null
+)
+
+@Serializable
+data class ApiResponse<T>(
+    val success: Boolean,
+    val data: T? = null,
+    val error: String? = null
+)
+```
+
+## Authentication (JWT)
+
+```kotlin
+import io.ktor.server.auth.*
+import io.ktor.server.auth.jwt.*
+import com.auth0.jwt.JWT
+import com.auth0.jwt.algorithms.Algorithm
+
+fun Application.configureAuth() {
+    val secret = environment.config.property("jwt.secret").getString()
+    val issuer = environment.config.property("jwt.issuer").getString()
+    val audience = environment.config.property("jwt.audience").getString()
+
+    install(Authentication) {
+        jwt("auth-jwt") {
+            realm = "Ktor Server"
+            verifier(
+                JWT
+                    .require(Algorithm.HMAC256(secret))
+                    .withIssuer(issuer)
+                    .withAudience(audience)
+                    .build()
+            )
+            validate { credential ->
+                if (credential.payload.audience.contains(audience)) {
+                    JWTPrincipal(credential.payload)
+                } else {
+                    null
+                }
+            }
+            challenge { _, _ ->
+                call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
+            }
+        }
+    }
+}
+
+// Protected routes
+fun Route.protectedRoutes() {
+    authenticate("auth-jwt") {
+        get("/profile") {
+            val principal = call.principal<JWTPrincipal>()
+            val userId = principal?.payload?.getClaim("userId")?.asString()
+            val user = userService.getUser(userId ?: "")
+            call.respond(user ?: HttpStatusCode.NotFound)
+        }
+    }
+}
+
+// Token generation
+fun generateToken(userId: String): String {
+    return JWT.create()
+        .withAudience(audience)
+        .withIssuer(issuer)
+        .withClaim("userId", userId)
+        .withExpiresAt(Date(System.currentTimeMillis() + 60000 * 60 * 24)) // 24h
+        .sign(Algorithm.HMAC256(secret))
+}
+```
+
+## Database Integration (Exposed)
+
+```kotlin
+import org.jetbrains.exposed.sql.*
+import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
+import org.jetbrains.exposed.sql.transactions.transaction
+
+object Users : Table() {
+    val id = varchar("id", 36)
+    val email = varchar("email", 255).uniqueIndex()
+    val name = varchar("name", 255)
+    val passwordHash = varchar("password_hash", 255)
+    val createdAt = long("created_at")
+
+    override val primaryKey = PrimaryKey(id)
+}
+
+class UserService(private val database: Database) {
+    suspend fun <T> dbQuery(block: suspend () -> T): T =
+        newSuspendedTransaction(Dispatchers.IO) { block() }
+
+    suspend fun getAllUsers(): List<User> = dbQuery {
+        Users.selectAll().map { toUser(it) }
+    }
+
+    suspend fun getUser(id: String): User? = dbQuery {
+        Users.select { Users.id eq id }
+            .mapNotNull { toUser(it) }
+            .singleOrNull()
+    }
+
+    suspend fun createUser(request: CreateUserRequest): User = dbQuery {
+        val id = UUID.randomUUID().toString()
+        val passwordHash = hashPassword(request.password)
+
+        Users.insert {
+            it[Users.id] = id
+            it[email] = request.email
+            it[name] = request.name
+            it[Users.passwordHash] = passwordHash
+            it[createdAt] = System.currentTimeMillis()
+        }
+
+        User(id, request.email, request.name, System.currentTimeMillis())
+    }
+
+    private fun toUser(row: ResultRow): User =
+        User(
+            id = row[Users.id],
+            email = row[Users.email],
+            name = row[Users.name],
+            createdAt = row[Users.createdAt]
+        )
+}
+```
+
+## Error Handling
+
+```kotlin
+import io.ktor.server.plugins.statuspages.*
+
+fun Application.configureErrorHandling() {
+    install(StatusPages) {
+        exception<Throwable> { call, cause ->
+            when (cause) {
+                is IllegalArgumentException -> {
+                    call.respond(
+                        HttpStatusCode.BadRequest,
+                        ApiResponse<Nothing>(success = false, error = cause.message)
+                    )
+                }
+                is NotFoundException -> {
+                    call.respond(
+                        HttpStatusCode.NotFound,
+                        ApiResponse<Nothing>(success = false, error = cause.message)
+                    )
+                }
+                else -> {
+                    call.respond(
+                        HttpStatusCode.InternalServerError,
+                        ApiResponse<Nothing>(success = false, error = "Internal server error")
+                    )
+                }
+            }
+        }
+
+        status(HttpStatusCode.NotFound) { call, status ->
+            call.respond(
+                status,
+                ApiResponse<Nothing>(success = false, error = "Resource not found")
+            )
+        }
+    }
+}
+
+class NotFoundException(message: String) : Exception(message)
+```
+
+## CORS Configuration
+
+```kotlin
+import io.ktor.server.plugins.cors.routing.*
+
+fun Application.configureCORS() {
+    install(CORS) {
+        allowMethod(HttpMethod.Options)
+        allowMethod(HttpMethod.Put)
+        allowMethod(HttpMethod.Delete)
+        allowMethod(HttpMethod.Patch)
+        allowHeader(HttpHeaders.Authorization)
+        allowHeader(HttpHeaders.ContentType)
+        allowCredentials = true
+        allowNonSimpleContentTypes = true
+
+        anyHost() // Development only
+        // allowHost("client-host", schemes = listOf("http", "https"))
+    }
+}
+```
+
+## WebSockets
+
+```kotlin
+import io.ktor.websocket.*
+import io.ktor.server.websocket.WebSockets
+import io.ktor.server.websocket.webSocket
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+
+fun Application.configureWebSockets() {
+    install(WebSockets) {
+        pingPeriod = Duration.ofSeconds(15)
+        timeout = Duration.ofSeconds(15)
+        maxFrameSize = Long.MAX_VALUE
+        masking = false
+    }
+
+    routing {
+        webSocket("/chat") {
+            val session = ChatSession(this)
+            chatService.addSession(session)
+
+            try {
+                for (frame in incoming) {
+                    when (frame) {
+                        is Frame.Text -> {
+                            val message = frame.readText()
+                            chatService.broadcast(message)
+                        }
+                        else -> {}
+                    }
+                }
+            } finally {
+                chatService.removeSession(session)
+            }
+        }
+    }
+}
+```
+
+## Testing
+
+```kotlin
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.server.testing.*
+import kotlin.test.*
+
+class ApplicationTest {
+    @Test
+    fun testGetUsers() = testApplication {
+        application {
+            configureRouting()
+            configureSerialization()
+        }
+
+        val response = client.get("/api/v1/users")
+        assertEquals(HttpStatusCode.OK, response.status)
+    }
+
+    @Test
+    fun testCreateUser() = testApplication {
+        application {
+            configureRouting()
+            configureSerialization()
+        }
+
+        val response = client.post("/api/v1/users") {
+            contentType(ContentType.Application.Json)
+            setBody(CreateUserRequest("test@example.com", "Test User", "password123"))
+        }
+
+        assertEquals(HttpStatusCode.Created, response.status)
+    }
+
+    @Test
+    fun testAuthenticatedRoute() = testApplication {
+        application {
+            configureAuth()
+            configureRouting()
+        }
+
+        val token = generateToken("user123")
+
+        val response = client.get("/api/v1/profile") {
+            header(HttpHeaders.Authorization, "Bearer $token")
+        }
+
+        assertEquals(HttpStatusCode.OK, response.status)
+    }
+}
+```
+
+## Quick Reference
+
+| Plugin | Purpose |
+|--------|---------|
+| `ContentNegotiation` | JSON serialization |
+| `Authentication` | JWT/OAuth2 auth |
+| `CORS` | Cross-origin requests |
+| `StatusPages` | Error handling |
+| `CallLogging` | Request logging |
+| `WebSockets` | WebSocket support |
+| `RateLimit` | Rate limiting |
+| `Compression` | Response compression |
+
+| Function | Purpose |
+|----------|---------|
+| `call.receive<T>()` | Parse request body |
+| `call.respond()` | Send response |
+| `call.parameters` | Query/path params |
+| `call.principal()` | Get authenticated user |
+| `authenticate { }` | Protect routes |
+| `route("/path") { }` | Group routes |

+ 380 - 0
.claude/skills/kotlin-specialist/references/multiplatform-kmp.md

@@ -0,0 +1,380 @@
+# Kotlin Multiplatform (KMP)
+
+## Project Structure
+
+```
+project/
+├── commonMain/
+│   ├── kotlin/
+│   │   ├── data/
+│   │   │   └── User.kt
+│   │   ├── repository/
+│   │   │   └── UserRepository.kt
+│   │   └── Platform.kt (expect)
+│   └── resources/
+├── androidMain/
+│   └── kotlin/
+│       └── Platform.android.kt (actual)
+├── iosMain/
+│   └── kotlin/
+│       └── Platform.ios.kt (actual)
+└── jvmMain/
+    └── kotlin/
+        └── Platform.jvm.kt (actual)
+```
+
+## Gradle Configuration
+
+```kotlin
+// build.gradle.kts
+plugins {
+    kotlin("multiplatform") version "1.9.22"
+    kotlin("plugin.serialization") version "1.9.22"
+}
+
+kotlin {
+    // JVM target
+    jvm {
+        compilations.all {
+            kotlinOptions.jvmTarget = "17"
+        }
+    }
+
+    // Android target
+    androidTarget {
+        compilations.all {
+            kotlinOptions.jvmTarget = "17"
+        }
+    }
+
+    // iOS targets
+    listOf(
+        iosX64(),
+        iosArm64(),
+        iosSimulatorArm64()
+    ).forEach { iosTarget ->
+        iosTarget.binaries.framework {
+            baseName = "shared"
+            isStatic = true
+        }
+    }
+
+    // JS target
+    js(IR) {
+        browser()
+        nodejs()
+    }
+
+    sourceSets {
+        val commonMain by getting {
+            dependencies {
+                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
+                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
+                implementation("io.ktor:ktor-client-core:2.3.7")
+            }
+        }
+
+        val commonTest by getting {
+            dependencies {
+                implementation(kotlin("test"))
+                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
+            }
+        }
+
+        val androidMain by getting {
+            dependencies {
+                implementation("io.ktor:ktor-client-okhttp:2.3.7")
+            }
+        }
+
+        val iosMain by getting {
+            dependencies {
+                implementation("io.ktor:ktor-client-darwin:2.3.7")
+            }
+        }
+
+        val jvmMain by getting {
+            dependencies {
+                implementation("io.ktor:ktor-client-cio:2.3.7")
+            }
+        }
+    }
+}
+```
+
+## Expect/Actual Pattern
+
+```kotlin
+// commonMain/kotlin/Platform.kt
+expect class Platform() {
+    val name: String
+    fun currentTimeMillis(): Long
+}
+
+expect fun getPlatform(): Platform
+
+// androidMain/kotlin/Platform.android.kt
+import android.os.Build
+
+actual class Platform {
+    actual val name: String = "Android ${Build.VERSION.SDK_INT}"
+
+    actual fun currentTimeMillis(): Long =
+        System.currentTimeMillis()
+}
+
+actual fun getPlatform(): Platform = Platform()
+
+// iosMain/kotlin/Platform.ios.kt
+import platform.UIKit.UIDevice
+import platform.Foundation.NSDate
+
+actual class Platform {
+    actual val name: String =
+        UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
+
+    actual fun currentTimeMillis(): Long =
+        (NSDate().timeIntervalSince1970 * 1000).toLong()
+}
+
+actual fun getPlatform(): Platform = Platform()
+```
+
+## Common Code Patterns
+
+```kotlin
+// commonMain - Shared business logic
+class UserRepository(private val api: ApiService) {
+    private val _users = MutableStateFlow<List<User>>(emptyList())
+    val users: StateFlow<List<User>> = _users.asStateFlow()
+
+    suspend fun loadUsers() {
+        try {
+            val result = api.getUsers()
+            _users.value = result
+        } catch (e: Exception) {
+            // Handle error
+        }
+    }
+}
+
+// Shared models
+@Serializable
+data class User(
+    val id: String,
+    val name: String,
+    val email: String,
+    val createdAt: Long
+)
+
+// Sealed class for platform-agnostic results
+sealed class Result<out T> {
+    data class Success<T>(val data: T) : Result<T>()
+    data class Error(val exception: Exception) : Result<Nothing>()
+    object Loading : Result<Nothing>()
+}
+```
+
+## Platform-Specific Implementations
+
+```kotlin
+// commonMain
+expect class DatabaseDriver()
+
+expect suspend fun DatabaseDriver.query(sql: String): List<Map<String, Any>>
+
+// androidMain
+import android.content.Context
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+actual class DatabaseDriver(private val context: Context) {
+    private val db: SupportSQLiteDatabase = // Initialize Android SQLite
+}
+
+actual suspend fun DatabaseDriver.query(sql: String): List<Map<String, Any>> =
+    withContext(Dispatchers.IO) {
+        // Android-specific query execution
+    }
+
+// iosMain
+import platform.Foundation.NSFileManager
+
+actual class DatabaseDriver() {
+    private val db = // Initialize iOS SQLite
+}
+
+actual suspend fun DatabaseDriver.query(sql: String): List<Map<String, Any>> =
+    withContext(Dispatchers.Default) {
+        // iOS-specific query execution
+    }
+```
+
+## Ktor Client Multiplatform
+
+```kotlin
+// commonMain
+class ApiClient {
+    private val client = HttpClient {
+        install(ContentNegotiation) {
+            json(Json {
+                prettyPrint = true
+                isLenient = true
+                ignoreUnknownKeys = true
+            })
+        }
+        install(Logging) {
+            level = LogLevel.INFO
+        }
+    }
+
+    suspend fun getUsers(): List<User> =
+        client.get("https://api.example.com/users").body()
+
+    suspend fun createUser(user: User): User =
+        client.post("https://api.example.com/users") {
+            contentType(ContentType.Application.Json)
+            setBody(user)
+        }.body()
+}
+```
+
+## Source Set Hierarchy
+
+```kotlin
+// Intermediate source sets for iOS
+kotlin {
+    sourceSets {
+        val commonMain by getting
+        val commonTest by getting
+
+        val iosMain by creating {
+            dependsOn(commonMain)
+        }
+
+        val iosX64Main by getting {
+            dependsOn(iosMain)
+        }
+
+        val iosArm64Main by getting {
+            dependsOn(iosMain)
+        }
+
+        val iosSimulatorArm64Main by getting {
+            dependsOn(iosMain)
+        }
+    }
+}
+```
+
+## Native Interop (iOS)
+
+```kotlin
+// iosMain - Calling Objective-C/Swift
+import platform.Foundation.NSBundle
+import platform.UIKit.UIApplication
+
+fun getAppVersion(): String =
+    NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String
+        ?: "Unknown"
+
+fun openURL(url: String) {
+    val nsUrl = NSURL.URLWithString(url)
+    UIApplication.sharedApplication.openURL(nsUrl ?: return)
+}
+
+// Freezing for thread safety (Kotlin/Native memory model)
+class IosViewModel {
+    private val scope = MainScope()
+
+    fun loadData() {
+        scope.launch {
+            val data = api.getData().freeze() // Freeze for iOS
+            updateUI(data)
+        }
+    }
+}
+```
+
+## Testing Multiplatform Code
+
+```kotlin
+// commonTest
+class UserRepositoryTest {
+    private lateinit var repository: UserRepository
+
+    @BeforeTest
+    fun setup() {
+        repository = UserRepository(FakeApiService())
+    }
+
+    @Test
+    fun testLoadUsers() = runTest {
+        repository.loadUsers()
+
+        val users = repository.users.value
+        assertEquals(2, users.size)
+    }
+}
+
+// Platform-specific tests
+// androidTest
+class AndroidUserRepositoryTest {
+    @Test
+    fun testAndroidSpecific() {
+        // Android-only test
+    }
+}
+
+// iosTest
+class IosUserRepositoryTest {
+    @Test
+    fun testIosSpecific() {
+        // iOS-only test
+    }
+}
+```
+
+## Publishing KMP Library
+
+```kotlin
+// build.gradle.kts
+plugins {
+    `maven-publish`
+}
+
+publishing {
+    publications {
+        create<MavenPublication>("kotlinMultiplatform") {
+            groupId = "com.example"
+            artifactId = "shared"
+            version = "1.0.0"
+        }
+    }
+
+    repositories {
+        maven {
+            url = uri("https://maven.pkg.github.com/user/repo")
+            credentials {
+                username = System.getenv("GITHUB_ACTOR")
+                password = System.getenv("GITHUB_TOKEN")
+            }
+        }
+    }
+}
+```
+
+## Quick Reference
+
+| Pattern | Purpose |
+|---------|---------|
+| `expect class` | Declare platform-specific type in common |
+| `actual class` | Implement platform-specific type |
+| `commonMain` | Shared code across all platforms |
+| `androidMain` | Android-specific implementations |
+| `iosMain` | iOS-specific implementations (all targets) |
+| `jvmMain` | JVM/Desktop-specific code |
+| `jsMain` | JavaScript-specific code |
+| `*Test` | Platform-specific tests |
+| `dependsOn` | Source set hierarchy |
+| `.freeze()` | iOS memory model (legacy) |
+| `kotlin("multiplatform")` | KMP Gradle plugin |

+ 499 - 0
.claude/skills/kotlin-testing-kmp/SKILL.md

@@ -0,0 +1,499 @@
+---
+name: kotlin-testing-kmp
+description: Use when designing, implementing, or reviewing tests in KMP projects — unit tests, instrumented tests, Compose Multiplatform UI tests, test doubles, test strategy, stability, performance, and screenshot testing.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "1.2.0"
+---
+
+# Kotlin Multiplatform Testing
+
+Use this skill when designing, implementing, or reviewing tests in a Kotlin Multiplatform project.
+
+This skill is intentionally strict. Its purpose is to keep the test suite fast, trustworthy, behavior-focused, and aligned with Android’s testing guidance while still respecting shared KMP boundaries and Compose Multiplatform testing patterns.
+
+## Primary goals
+
+The testing strategy should optimize for:
+
+- strong confidence in business logic
+- fast feedback from local/unit tests
+- clear separation between local, Robolectric, instrumented, and UI tests
+- behavior-focused tests instead of implementation-detail tests
+- appropriate use of test doubles
+- stable and performant larger tests
+- isolated testing of shared KMP logic
+- practical Compose Multiplatform UI test coverage
+- targeted screenshot testing where visual regressions matter
+
+Do not optimize for raw coverage percentages alone.
+Optimize for confidence, speed, signal quality, and maintainability.
+
+---
+
+## Official defaults to prefer
+
+Unless the project has a strong reason not to, prefer these defaults:
+
+- many small local/unit tests
+- fewer integration tests
+- fewer end-to-end/UI tests than lower-level tests
+- tests focused on user-visible behavior and business outcomes
+- use of test doubles to isolate dependencies
+- `kotlin.test` for assertions and test declarations in shared KMP test source sets — this is the cross-platform test API recommended by JetBrains and works across JVM, native, and JS/Wasm targets
+- local tests in `src/test` (JVM-side tests in Android modules)
+- Android instrumented tests in `src/androidTest`
+- Robolectric only when Android framework behavior is needed but device execution is not necessary
+- common Compose Multiplatform UI tests where the behavior is truly shared
+- stable UI tests with clear synchronization and controlled environment setup
+- screenshot tests for important visual surfaces when appearance regressions matter
+
+---
+
+## Test strategy defaults
+
+### 1. Testing pyramid
+
+Prefer a pyramid-shaped suite:
+
+- many unit tests
+- fewer integration tests
+- fewer UI/end-to-end tests
+
+Review expectation:
+- lower-level tests should catch most regressions
+- larger tests should validate integration and user workflows, not replace unit coverage
+
+Flag as a concern when:
+- most confidence depends on slow UI tests
+- business logic is only exercised through end-to-end flows
+- the suite is top-heavy and expensive to run
+
+### 2. Behavior over implementation detail
+
+Prefer tests that validate:
+- observable outputs
+- state transitions
+- user-visible effects
+- business rules
+
+Be cautious with tests that lock down:
+- internal private structure
+- implementation-specific sequencing that users do not observe
+- framework internals
+
+Flag as a concern when:
+- tests fail after harmless refactors
+- mocks assert incidental calls instead of real behavior
+- UI tests are verifying widget internals rather than actual interaction outcomes
+
+---
+
+## Test-scope review dimensions
+
+### 3. What should be tested first
+
+Prioritize tests for:
+
+- business rules
+- domain/use-case behavior
+- repository coordination logic
+- DTO/domain/UI mapping
+- state-holder transitions
+- failure and retry handling
+- navigation decision logic where important
+- shared UI behavior that is meaningful across targets
+
+Flag as a concern when:
+- trivial pass-through code is heavily tested while important rules are not
+- mapping and error paths are untested
+- state transitions are inferred rather than verified
+
+### 4. Local unit tests
+
+Local tests should be the default choice for fast feedback.
+
+For **shared KMP modules**, tests in `commonTest` (using `kotlin.test`) run on all declared targets — JVM, native, JS/Wasm — making them the correct layer for shared business logic. Do not confuse `commonTest` (shared KMP tests) with `src/test` (Android/JVM-local tests).
+
+Check whether:
+- pure Kotlin/shared logic is tested in `commonTest` using `kotlin.test`, not pushed into Android-specific test layers
+- Android-specific behavior is tested in `src/test` (local JVM) or `src/androidTest` (instrumented), not in shared test source sets
+- test setup avoids Android device/emulator dependency when unnecessary
+- most business logic tests live in the lowest practical test layer
+
+Review expectations:
+- shared logic in KMP should be exercised in `commonTest` first, before platform-specific layers
+- `src/test` remains the main home for fast-running JVM-side tests on Android-only modules
+- local tests are preferred unless the behavior truly needs Android runtime support
+
+Flag as a concern when:
+- shared business logic is only tested in Android-specific layers (`src/androidTest`) without reason
+- the suite pays emulator/device cost for logic that could be a `commonTest` unit test
+- `kotlin.test` is not used in shared source sets — shared tests depend on JUnit directly, breaking non-JVM targets
+
+### 4a. kotlin.test for shared source sets
+
+In KMP projects, shared test source sets (e.g., `commonTest`) should use `kotlin.test` for assertions and test structure. `kotlin.test` provides `@Test`, `assertEquals`, `assertNotNull`, `assertFailsWith`, and other essentials that compile correctly for all KMP targets (JVM, native, JS, Wasm).
+
+Check whether:
+- shared test code uses `kotlin.test` rather than JUnit or platform-specific assertion libraries
+- `kotlin.test` is declared as a dependency in the `commonTest` source set
+- platform-specific test libraries (JUnit, XCTest wrappers) are added only in platform-specific test source sets when needed
+
+Flag as a concern when:
+- JUnit annotations or assertions appear in `commonTest` without a JVM-only source set constraint
+- shared tests fail on native or JS targets because of JVM-specific test infrastructure
+- `kotlin.test` is absent from a KMP project's test dependencies despite having shared business logic
+
+### 5. Robolectric usage
+
+Robolectric is an Android-only testing tool. It is not available in KMP shared test source sets — it can only be used in Android-specific test source sets (`src/test` in an Android module, or an `androidUnitTest` source set in a KMP module). Do not attempt to configure Robolectric in `commonTest`.
+
+Robolectric is useful when Android-dependent behavior must be exercised on the JVM without a real device or emulator.
+
+Check whether:
+- Robolectric is used for Android framework interactions that do not require full device/emulator fidelity
+- it is placed in Android-specific test source sets, not shared KMP test source sets
+- it is not used as a default for tests that could be plain local tests or `kotlin.test` unit tests
+- the project uses it intentionally rather than as a catch-all compromise
+
+Flag as a concern when:
+- Robolectric is used for pure business logic that does not interact with Android APIs
+- device-only behavior is assumed to be fully proven by Robolectric alone
+- the suite becomes slow and brittle because Robolectric is overused
+- Robolectric dependencies appear in shared KMP source sets
+
+### 6. Instrumented tests
+
+Instrumented tests should cover behavior that genuinely needs a real Android runtime, emulator, or device.
+
+Check whether:
+- Android integration behavior is validated in `src/androidTest`
+- tests that need framework/runtime fidelity are placed here
+- instrumented tests are selective rather than the default
+
+Good candidates:
+- platform integration
+- app-component interactions
+- behavior that depends on real Android runtime semantics
+- high-value UI flows
+
+Flag as a concern when:
+- most feature validation lives only in instrumented tests
+- instrumented tests are used for logic that should be local
+- test layering is blurry and costly
+
+### 7. Compose Multiplatform UI tests
+
+Compose Multiplatform supports shared UI testing.
+
+Check whether:
+- shared UI behavior is tested in common code when it is genuinely shared
+- `runComposeUiTest` is used for common Compose Multiplatform UI tests (note: as of mid-2025, `runComposeUiTest` requires `@OptIn(ExperimentalTestApi::class)`; verify stability status against the current Compose Multiplatform release)
+- platform-specific setup is added only when required
+- shared UI tests focus on semantics and observable behavior
+
+Flag as a concern when:
+- shared UI can only be validated through Android-only tests without reason
+- common UI tests are skipped despite heavily shared Compose behavior
+- tests depend too much on implementation details instead of semantics
+
+### 8. AndroidX Test setup discipline
+
+AndroidX Test setup should be coherent and intentional.
+
+Check whether:
+- test runners, rules, and AndroidX Test dependencies are configured consistently
+- instrumented tests use the standard test infrastructure instead of ad hoc setup
+- test environment setup is centralized enough to avoid drift
+
+Flag as a concern when:
+- instrumented test setup differs arbitrarily across modules
+- runner/rule configuration is duplicated or inconsistent
+- test infrastructure itself becomes hard to trust
+
+---
+
+## Test doubles
+
+### 9. Appropriate test-double choice
+
+Choose test doubles deliberately.
+
+Common categories:
+- fake
+- mock
+- stub
+- spy
+
+Prefer:
+- fakes for repositories, data sources, and meaningful behavior simulation
+- stubs for simple fixed responses
+- mocks only when interaction verification is actually the point
+- spies sparingly
+
+Flag as a concern when:
+- everything is mocked by default
+- tests are interaction-heavy but behavior-light
+- a fake would express the scenario more clearly than a deep mock tree
+
+### 10. Dependency isolation
+
+Check whether:
+- tests isolate external systems appropriately
+- network, database, file, and platform dependencies are replaced when the test does not need them
+- doubles reduce flakiness and improve speed
+
+Flag as a concern when:
+- tests depend on real external services unnecessarily
+- fake/test data behavior diverges so much from production that the test misleads
+- the chosen double makes the test harder to understand
+
+---
+
+## Stability and performance for larger tests
+
+### 11. Big-test stability
+
+Android’s guidance treats stability as a first-class quality attribute for larger tests.
+
+Check whether:
+- tests control asynchronous work predictably
+- environment setup is repeatable
+- test state is isolated between runs
+- flakiness sources are identified and reduced
+- retries are not hiding real nondeterminism
+
+Flag as a concern when:
+- UI/integration tests pass only intermittently
+- timing assumptions replace synchronization
+- global shared state leaks between tests
+
+### 12. Performance of instrumented tests
+
+Instrumented tests should be optimized because they are expensive.
+
+Check whether:
+- the instrumented suite stays focused on high-value scenarios
+- setup and teardown are not wasteful
+- large tests are not duplicated unnecessarily across many layers
+- performance-sensitive test suites are monitored and trimmed
+
+Flag as a concern when:
+- large tests are used where local tests would suffice
+- startup/setup costs dominate every test
+- the suite becomes too slow to run regularly
+
+---
+
+## UI testing guidance
+
+### 13. UI tests should validate behavior
+
+UI tests should focus on what the user can do and observe.
+
+Check whether:
+- assertions reflect visible behavior or meaningful semantics
+- user actions are modeled realistically
+- UI tests validate flows, not incidental structure
+
+Flag as a concern when:
+- tests lock onto fragile implementation details
+- the suite checks internal tree shapes with little user value
+- behavior is under-tested while low-value rendering details dominate
+
+### 14. Screenshot testing
+
+Screenshot tests are useful for detecting visual regressions on stable surfaces.
+
+Check whether:
+- screenshots are applied to important visual states
+- expected variants are intentional and controlled
+- screenshots complement, rather than replace, behavioral tests
+- visual baselines are maintained carefully
+
+Good candidates:
+- design-system components
+- stable destination screens
+- major adaptive-layout variants
+- key theme or locale states where appearance matters
+
+Flag as a concern when:
+- screenshot tests are used as the main correctness signal for behavior
+- baselines churn constantly because surfaces are too unstable
+- too many low-value screenshots make maintenance noisy
+
+---
+
+## KMP-specific review dimensions
+
+### 15. Shared-vs-platform test placement
+
+Test-source-set placement should mirror code-source-set placement:
+
+- `commonTest` (using `kotlin.test`): shared business logic, domain rules, shared repository behavior, Compose Multiplatform UI behavior
+- Android `src/test` (JUnit/Robolectric): Android-specific logic, ViewModel behavior where ViewModel is Android-only
+- Android `src/androidTest`: Android integration, real framework behavior, UI flows on Android
+- iOS/native test source sets: iOS-platform-specific behavior
+
+Check whether:
+- shared behavior is tested in `commonTest` using `kotlin.test`
+- Android-specific behavior remains in Android test layers
+- platform-specific assertions do not leak into shared tests without reason
+- `kotlin.test` is used consistently in shared source sets (not JUnit4/JUnit5 directly, which are JVM-only)
+
+Flag as a concern when:
+- shared logic is only tested in Android-specific layers
+- JUnit annotations appear in `commonTest` (breaks non-JVM compilation targets)
+- platform-specific test setup (context, activity, application) pollutes shared test code
+- the team does not know which test layer owns which behavior
+
+### 16. State-holder and flow testing
+
+Check whether:
+- state-holder outputs are tested deterministically
+- loading, success, empty, error, retry, and partial-data paths are covered
+- one-time events are tested separately from persistent state
+- flow-based logic is verified without over-relying on UI tests
+
+Flag as a concern when:
+- state behavior is inferred only through UI rendering
+- event emission is untested
+- async/state tests are timing-based rather than deterministic
+
+### 17. Repository and mapping testing
+
+Check whether:
+- repositories are tested for coordination logic, source-of-truth behavior, and error handling
+- mapping is tested directly
+- test doubles are used where they provide clarity
+
+Flag as a concern when:
+- mapping bugs can only be caught through broad integration tests
+- repository conflict resolution is untested
+- data-layer correctness depends mainly on manual QA
+
+---
+
+## Severity framework
+
+### High severity
+Likely to undermine trust in the suite.
+
+Examples:
+- business logic only covered by UI/instrumented tests
+- highly flaky big tests
+- no clear separation between local and instrumented tests
+- shared KMP logic untested or only tested through one platform
+- unstable UI tests driven by timing assumptions
+
+### Medium severity
+Workable, but likely to create maintenance cost.
+
+Examples:
+- overuse of mocks
+- Robolectric used too broadly
+- screenshot coverage is noisy or poorly targeted
+- instrumented suite too large for its value
+- important state transitions missing direct tests
+
+### Low severity
+Structurally acceptable but worth improving.
+
+Examples:
+- test naming could be clearer
+- some fakes could replace mocks
+- preview/screenshot variants could be better targeted
+
+---
+
+## Required output format
+
+When performing the review, respond with:
+
+1. **Testing summary**
+   - overall strategy
+   - test pyramid shape
+   - local / Robolectric / instrumented / UI / screenshot split
+   - shared-vs-platform test placement
+
+2. **What is structurally sound**
+   - concrete strengths only
+
+3. **Issues by review dimension**
+   - strategy and pyramid
+   - what is being tested
+   - local tests
+   - Robolectric usage
+   - instrumented tests
+   - Compose Multiplatform UI tests
+   - AndroidX Test setup
+   - test doubles
+   - stability
+   - performance
+   - UI behavior tests
+   - screenshot tests
+   - shared-vs-platform placement
+   - state-holder/flow tests
+   - repository/mapping tests
+
+4. **Severity for each issue**
+   - high / medium / low
+
+5. **Concrete recommendations**
+   - which tests should move down the pyramid
+   - where to replace mocks with fakes/stubs
+   - what should become local vs instrumented
+   - what shared UI should be covered in common tests
+   - where screenshot tests add value
+   - how to reduce flakiness and runtime
+
+6. **Suggested target structure**
+   - proposed test-layer split if useful
+
+7. **Open risks**
+   - migration cost
+   - flakiness still to investigate
+   - platform-specific validation still required
+
+---
+
+## Tone
+
+Be direct and practical.
+Do not praise a large suite just because it has many tests.
+If the testing strategy is weak, say why clearly.
+
+---
+
+## Anti-patterns to flag aggressively
+
+- top-heavy test suites dominated by UI/end-to-end tests
+- business logic verified only through instrumented/UI tests
+- unstable tests that rely on sleeps or timing guesses
+- heavy mock usage where fakes would be clearer
+- Android-specific assumptions inside shared KMP tests
+- screenshot tests used as a substitute for behavior tests
+- duplicated large tests across multiple layers
+- expensive instrumented tests with low signal
+
+---
+
+## References
+
+- Android: Fundamentals of testing: https://developer.android.com/training/testing/fundamentals
+- Android: What to test: https://developer.android.com/training/testing/fundamentals/what-to-test
+- Android: Test doubles: https://developer.android.com/training/testing/fundamentals/test-doubles
+- Android: Testing strategies: https://developer.android.com/training/testing/fundamentals/strategies
+- Android: Local unit tests: https://developer.android.com/training/testing/local-tests
+- Android: Robolectric: https://developer.android.com/training/testing/local-tests/robolectric
+- Android: Instrumented tests: https://developer.android.com/training/testing/instrumented-tests
+- Android: Big test stability: https://developer.android.com/training/testing/instrumented-tests/stability
+- Android: Instrumented test performance: https://developer.android.com/training/testing/instrumented-tests/performance
+- Android: AndroidX Test setup: https://developer.android.com/training/testing/instrumented-tests/androidx-test-libraries/test-setup
+- Android: UI tests: https://developer.android.com/training/testing/ui-tests
+- Android: Behavior UI tests: https://developer.android.com/training/testing/ui-tests/behavior
+- Android: Screenshot testing: https://developer.android.com/training/testing/ui-tests/screenshot
+- Compose Multiplatform: Testing UI: https://kotlinlang.org/docs/multiplatform/compose-test.html
+- kotlin.test API: https://kotlinlang.org/api/latest/kotlin.test/

+ 438 - 0
.claude/skills/kotlin-ui-adaptive-resources/SKILL.md

@@ -0,0 +1,438 @@
+---
+name: kotlin-ui-adaptive-resources
+description: Use when designing, implementing, or reviewing adaptive Compose UI for KMP or Android projects — window-size layouts, adaptive navigation, canonical layouts, multi-window support, and resource strategy.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "1.1.0"
+---
+
+# Adaptive UI and Resource Strategy
+
+Use this skill when designing, implementing, or reviewing adaptive UI in a Compose-based project.
+
+This skill is intentionally strict. Its purpose is to keep layouts resilient across display sizes, window sizes, orientations, fold states, and resizable environments while preserving clear UI architecture and avoiding fragile one-device assumptions.
+
+## Primary goals
+
+The adaptive strategy should optimize for:
+
+- support for a wide range of display and window sizes
+- structured layout changes rather than ad hoc breakpoints
+- navigation chrome that adapts with available space
+- correct use of canonical multi-pane layouts where appropriate
+- resilience in split-screen, freeform, and desktop-style windowing
+- resource and presentation decisions that stay configurable
+- Compose layouts that remain understandable under adaptation pressure
+- previewability and testability across adaptive states
+
+Do not treat “works on my phone” as sufficient.
+Adaptive UI should remain coherent when the window changes shape, size, or posture.
+
+---
+
+## Official defaults to prefer
+
+Unless the project has a strong reason not to, prefer:
+
+- responsive and adaptive UI that supports a wide range of screens and app window sizes
+- window size classes as the primary high-level breakpoint system
+- canonical layouts such as list-detail, feed, and supporting-pane when they fit the use case
+- adaptive navigation patterns that switch chrome based on available space
+- layouts that remain usable in multi-window mode and desktop-style windowing
+- resource and presentation values that stay configurable rather than hard-coded
+- Compose layouts whose modifier chains and measurement behavior remain explainable
+
+---
+
+## Review dimensions
+
+### 1. Adaptive mindset and target environments
+
+Check whether the design explicitly supports:
+
+- phones
+- tablets
+- foldables
+- ChromeOS / large-screen environments
+- portrait and landscape
+- resizable windows
+- split-screen mode
+- desktop windowing / freeform windows where applicable
+
+Flag as a concern when:
+- the design assumes one full-screen phone window shape
+- large-screen behavior is just stretched phone UI
+- resizable environments are ignored
+- adaptation is deferred until after feature implementation
+
+### 2. Window size class strategy
+
+Window size classes are the default high-level tool for adaptive decisions. In Compose, `WindowWidthSizeClass` and `WindowHeightSizeClass` (from the `androidx.compose.material3.adaptive` or `androidx.window` artifact) classify the current window into compact, medium, or expanded buckets. `rememberWindowAdaptiveInfo()` (from `androidx.compose.material3.adaptive`) is the current recommended API for querying the full adaptive context including window size and posture.
+
+Check whether:
+- major layout decisions are driven by window size classes rather than raw dp breakpoints
+- `rememberWindowAdaptiveInfo()` or equivalent structured APIs are used at the root shell level
+- breakpoints are not reinvented casually without reason
+- navigation chrome and pane layout decisions are tied to structured size input
+- fine-grained layout work sits underneath, rather than replacing, size-class strategy
+
+Flag as a concern when:
+- arbitrary pixel/dp thresholds replace standard size-class reasoning
+- different parts of the app use incompatible breakpoint logic
+- layout changes are too ad hoc to scale consistently
+- window-size queries are scattered across many composables instead of hoisted to the shell
+
+### 3. Navigation adaptation
+
+Adaptive navigation should change with available space.
+
+Check whether:
+- bottom navigation, navigation rail, drawer, or other shell chrome are chosen intentionally based on window size
+- current destination tracking remains stable while the chrome changes
+- navigation adaptation is a shell concern rather than duplicated in every feature
+
+Flag as a concern when:
+- phone navigation chrome is forced onto large layouts without reason
+- large-screen navigation is bolted on with special cases
+- shell adaptation and route state are tightly tangled
+
+### 4. Canonical layout choice
+
+Android’s canonical layouts are proven patterns for common use cases.
+
+Check whether the design should use:
+- list-detail
+- supporting pane
+- feed-like or other canonical structures
+
+Prefer canonical layouts when the product problem matches them.
+
+Flag as a concern when:
+- bespoke layouts re-solve a standard list-detail or supporting-pane problem poorly
+- large-screen UI is over-customized without product value
+- canonical multi-pane opportunities are missed
+
+### 5. List-detail behavior
+
+For master-detail style problems, review whether list-detail behavior is properly adaptive.
+
+Check whether:
+- compact windows can show one pane at a time
+- larger windows can present list and detail simultaneously
+- selection state and detail navigation remain coherent across size changes
+- back behavior still makes sense when moving between single-pane and multi-pane presentations
+
+Flag as a concern when:
+- list-detail patterns are modeled as unrelated screens with no shared selection state
+- expanding to large layouts creates duplicated or conflicting detail logic
+- detail presentation is not resilient to resizing
+
+### 6. Supporting-pane behavior
+
+Supporting-pane layouts should be used when a secondary pane adds context or tools rather than acting like a full peer destination.
+
+Check whether:
+- the supporting pane has a clear role
+- supporting content collapses gracefully when space is constrained
+- pane visibility and priority rules are explicit
+
+Flag as a concern when:
+- supporting-pane content becomes a permanent cluttered sidebar
+- collapse/expand behavior is implicit and fragile
+- pane ownership is unclear
+
+### 7. Multi-window and resizable-window support
+
+Multi-window mode means the app may run side-by-side, stacked, or in a resizable freeform window.
+
+Check whether:
+- the app remains functional in smaller-than-expected windows
+- layout assumptions are based on current window bounds, not only device type
+- state and layout respond correctly to window resizing
+- split-screen and desktop windowing are considered for important flows
+
+Flag as a concern when:
+- device category is used as a proxy for actual available space
+- resizing breaks layout hierarchy or interaction patterns
+- important content becomes inaccessible in constrained windows
+
+### 8. Orientation, aspect ratio, and resizability assumptions
+
+Orientation locks and aspect-ratio assumptions are increasingly fragile on large/resizable devices. Android 16 was announced to further reduce the effect of several of these restrictions on large screens for apps targeting API 36 (verify against the current Android 16 compatibility documentation, as this behavior was in development as of mid-2025).
+
+Android 16 (API 36) is expected to further reduce the effect of orientation lock and aspect-ratio restriction APIs on large screens for apps targeting API 36. This behavior was announced prior to Android 16's release — verify its current status in the Android 16 release notes or behavior changes documentation.
+
+Check whether:
+- the UI can adapt rather than relying on orientation locks
+- layout logic depends on current window size and structure rather than a fixed aspect ratio assumption
+- resizability is treated as normal rather than exceptional
+
+Flag as a concern when:
+- layout depends on portrait-only or landscape-only assumptions
+- the app relies on fixed aspect-ratio expectations that may no longer be honored on newer OS versions
+- resizing support is effectively disabled in architecture rather than handled in UI
+- orientation/aspect-ratio restrictions are used as a substitute for proper adaptive layout design
+
+### 9. Adaptive do’s and don’ts
+
+Use adaptive design principles consistently.
+
+Prefer:
+- current-window reasoning instead of device stereotypes
+- scalable layouts instead of stretched single-column phone UI
+- pane/chrome changes that preserve task flow
+- explicit adaptation strategy for important user journeys
+
+Avoid:
+- hard-coded one-device assumptions
+- content that becomes too sparse or too crowded on larger windows
+- duplicated flows created only to support one size class
+- adaptation that changes too much without preserving user mental model
+
+Flag as a concern when:
+- adaptation is visually inconsistent across screens
+- users must relearn flows purely because the window got larger
+- the UI wastes large-screen space or overloads compact screens
+
+### 10. Layout structure quality
+
+Adaptive UI still depends on good Compose layout structure.
+
+Check whether:
+- layout trees are understandable
+- rows, columns, boxes, lazy containers, scaffolds, and panes are used clearly
+- the shell is decomposed into meaningful layout responsibilities
+- layout adaptation does not turn root composables into giant conditional trees
+
+Flag as a concern when:
+- one giant composable owns all adaptive branches inline
+- layout structure is too tangled to reason about
+- adaptation logic is duplicated across many screens
+
+### 11. Modifier discipline
+
+Modifier order and composition affect layout and behavior.
+
+Check whether:
+- modifier chains remain readable
+- modifier order is intentional
+- adaptive behavior is not hidden inside long opaque modifier chains
+- size, padding, offset, click, visibility, and scroll behavior are composed in a way that is understandable
+
+Flag as a concern when:
+- modifier order creates accidental layout behavior
+- the layout can only be understood by trial and error
+- adaptive rules are buried in long modifier chains
+
+### 12. Intrinsic measurements
+
+Compose normally measures children once; intrinsic measurements are for cases where a parent layout needs child size information before normal measurement. In adaptive UI, this pattern can appear when a pane needs to size itself relative to sibling content — but it is often a sign that the layout structure needs rethinking rather than a special measurement pass.
+
+Check whether:
+- intrinsic measurements are used only when justified by a real layout requirement
+- they solve a specific adaptive or measurement problem that has no cleaner structural solution
+- they do not become a default fix for unclear layout design
+
+Flag as a concern when:
+- intrinsic sizing is scattered casually through adaptive layout code
+- the layout relies on intrinsics where a clearer pane/scaffold structure would be better
+- intrinsic behavior is used without understanding the measurement-pass cost
+
+### 13. Alignment lines
+
+Alignment lines let parent layouts align children by semantically meaningful baselines rather than raw bounds — for example, aligning a label's first text baseline to another element's text baseline across different adaptive layouts. This is relevant in adaptive UI when two panes or panels need to visually align even as their internal content structure differs.
+
+Check whether:
+- alignment lines are used for real cross-composable alignment requirements in adaptive layout contexts
+- custom alignment behavior is documented enough to be understood by future contributors
+- they support reusable layout polish rather than clever but obscure tricks
+
+Flag as a concern when:
+- alignment lines are used to patch unclear layout structure instead of fixing the structure
+- custom alignment contracts are hidden and fragile
+- maintainers cannot explain the alignment requirement that motivated the line
+
+### 14. Visibility and on-screen behavior
+
+Visibility tracking modifiers can support analytics, autoplay/pause behavior, and state changes based on whether content is actually visible.
+
+Check whether:
+- visibility tracking is used for clear product or performance reasons
+- analytics and resource-management behavior are tied to meaningful visibility semantics
+- visibility callbacks do not create accidental recomposition or side-effect issues
+
+Flag as a concern when:
+- visibility modifiers are added everywhere without purpose
+- side effects triggered by visibility are unstable or repetitive
+- resource control depends on poorly defined visibility assumptions
+
+### 15. Resource and presentation strategy
+
+Adaptive UI should keep presentation values configurable.
+
+Check whether:
+- strings, dimensions, icons, and other presentation concerns are not buried inside business logic
+- window-size-driven presentation differences stay in UI/presentation layers
+- resource decisions are organized enough to evolve with additional layouts, locales, themes, or size variants
+
+Flag as a concern when:
+- adaptive values are hard-coded deep in feature logic
+- presentation choices are scattered across domains that should not own them
+- future adaptive variants would require broad refactors
+
+### 16. Previewability
+
+Adaptive UIs should be previewable in meaningful states.
+
+Check whether:
+- compact, medium, and expanded variants can be previewed where useful
+- list-detail and supporting-pane variants can be previewed separately
+- root shell and destination content can be inspected without full app bootstrapping
+
+Flag as a concern when:
+- adaptive review requires always running the whole app
+- layout branches are too entangled for previews
+- preview coverage ignores the important adaptive states
+
+### 17. Testability
+
+Adaptive decisions should be testable as behavior, not only manually inspected.
+
+Check whether:
+- size-class-driven shell decisions can be validated
+- pane visibility rules can be tested
+- state survives or adapts coherently across resize-related changes
+- large-screen and constrained-window scenarios have intentional verification paths
+
+Flag as a concern when:
+- adaptive correctness depends only on manual QA
+- resizing behavior is too implicit to verify
+- shell adaptation is hard-coded in a way that resists testing
+
+---
+
+## Severity framework
+
+### High severity
+Likely to cause broken or misleading adaptive behavior.
+
+Examples:
+- app only works well in one full-screen phone shape
+- no structured size-class strategy
+- major content inaccessible in multi-window mode
+- hard dependence on orientation/aspect-ratio assumptions
+- list-detail/supporting-pane logic breaks on resize
+
+### Medium severity
+Workable, but likely to create maintenance cost or UX inconsistency.
+
+Examples:
+- adaptive navigation is patchy
+- canonical layouts are ignored where they fit well
+- modifier/layout structure makes adaptive behavior brittle
+- previews do not cover important adaptive variants
+
+### Low severity
+Structurally acceptable but worth improving.
+
+Examples:
+- modifier order could be clarified
+- some adaptive states could be previewed better
+- resource ownership is slightly scattered
+
+---
+
+## Required output format
+
+When performing the review, respond with:
+
+1. **Adaptive UI summary**
+   - target environments
+   - size-class strategy
+   - navigation adaptation
+   - canonical layout choice
+   - multi-window / resize posture
+   - resource strategy
+
+2. **What is structurally sound**
+   - concrete strengths only
+
+3. **Issues by review dimension**
+   - adaptive mindset
+   - window size classes
+   - navigation adaptation
+   - canonical layout choice
+   - list-detail
+   - supporting-pane
+   - multi-window support
+   - orientation/aspect-ratio/resizability assumptions
+   - adaptive do’s/don’ts
+   - layout structure
+   - modifier discipline
+   - intrinsic measurements
+   - alignment lines
+   - visibility behavior
+   - resource strategy
+   - previewability
+   - testability
+
+4. **Severity for each issue**
+   - high / medium / low
+
+5. **Concrete recommendations**
+   - exact layout/shell changes
+   - what should become size-class-driven
+   - where canonical layouts should replace custom structure
+   - what should move into previews/tests
+   - where modifier/resource ownership should be cleaned up
+
+6. **Suggested target structure**
+   - proposed shell / pane / destination / resource split if useful
+
+7. **Open risks**
+   - migration cost
+   - UX/regression risk during adaptation
+   - platform/window scenarios still to validate
+
+---
+
+## Tone
+
+Be direct and practical.
+Do not praise a layout just because it stretches.
+If the adaptive strategy is weak, say why clearly.
+
+---
+
+## Anti-patterns to flag aggressively
+
+- designing for one phone-sized full-screen window only
+- device-type assumptions used instead of current window-size reasoning
+- stretched phone UI presented as tablet support
+- no adaptive navigation strategy
+- bespoke multi-pane designs where canonical layouts would fit
+- brittle resize behavior
+- long opaque modifier chains hiding adaptive logic
+- casual intrinsic-measurement usage
+- visibility tracking without clear purpose
+- adaptive UI that cannot be previewed or tested meaningfully
+
+---
+
+## References
+
+- Android: Adaptive layouts in Compose: https://developer.android.com/develop/ui/compose/layouts/adaptive
+- Android: Support different display sizes: https://developer.android.com/develop/ui/compose/layouts/adaptive/support-different-display-sizes
+- Android: Use window size classes: https://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes
+- Android: Support multi-window mode: https://developer.android.com/develop/ui/compose/layouts/adaptive/support-multi-window-mode
+- Android: Orientation, aspect ratio, and resizability: https://developer.android.com/develop/ui/compose/layouts/adaptive/app-orientation-aspect-ratio-resizability
+- Android: Build adaptive navigation: https://developer.android.com/develop/ui/compose/layouts/adaptive/build-adaptive-navigation
+- Android: Canonical layouts: https://developer.android.com/develop/ui/compose/layouts/adaptive/canonical-layouts
+- Android: List-detail layout: https://developer.android.com/develop/ui/compose/layouts/adaptive/list-detail
+- Android: Supporting pane layout: https://developer.android.com/develop/ui/compose/layouts/adaptive/build-a-supporting-pane-layout
+- Android: Adaptive do's and don'ts: https://developer.android.com/develop/ui/compose/layouts/adaptive/adaptive-dos-and-donts
+- Android: Alignment lines in Compose: https://developer.android.com/develop/ui/compose/layouts/alignment-lines
+- Android: Intrinsic measurements in Compose: https://developer.android.com/develop/ui/compose/layouts/intrinsic-measurements
+- Android: Visibility modifiers in Compose: https://developer.android.com/develop/ui/compose/layouts/visibility-modifiers
+- Compose Multiplatform: Adaptive layouts: https://kotlinlang.org/docs/multiplatform/compose-adaptive-layouts.html

+ 395 - 0
.claude/skills/kotlin-ui-compose-multiplatform/SKILL.md

@@ -0,0 +1,395 @@
+---
+name: kotlin-ui-compose-multiplatform
+description: Use when designing, implementing, or reviewing shared UI in Compose Multiplatform projects, including state-driven architecture, composable decomposition, layout/modifier discipline, adaptive behavior, and previewable shared UI in common source sets.
+license: Apache-2.0
+metadata:
+  author: Mariano Miani
+  version: "1.2.0"
+---
+
+# Compose Multiplatform UI
+
+Use this skill when designing, implementing, or reviewing shared UI in a Compose Multiplatform project.
+
+This skill is intentionally strict. Its purpose is to keep shared UI declarative, state-driven, decomposed, adaptive, previewable, and cleanly separated from business logic and platform entry concerns.
+
+## Primary goals
+
+The UI design should optimize for:
+
+- state-driven rendering
+- clear composable decomposition
+- business logic outside composables
+- layout structure that remains understandable
+- modifier usage that is intentional and explainable
+- adaptive behavior across different window sizes
+- previewability in common code
+- clean separation between shared UI and platform-specific startup or OS integration
+
+Do not treat Compose UI as just “screens and widgets”.
+Treat it as part of app architecture.
+
+## Official defaults to prefer
+
+Unless the project has a strong reason not to, prefer:
+
+- UI driven from immutable state
+- state hoisting when multiple composables need to coordinate state
+- side effects handled deliberately rather than embedded in rendering paths
+- composables focused on rendering and interaction, not business rules
+- shared UI in `commonMain` when valid across targets
+- platform-specific UI bootstrapping and OS integration in platform source sets
+- adaptive layouts driven by structured window-size reasoning
+- previewable shared UI with `@Preview` where useful
+
+---
+
+## KMP project-structure expectations
+
+Compose Multiplatform projects typically place shared Compose code in a shared source set such as `composeApp/src/commonMain/kotlin`, with platform-specific code in source sets such as `androidMain` and `iosMain`.
+
+Review expectations:
+- shared composables, shared UI state models, and reusable UI components belong in shared source sets when they are valid across targets
+- platform entry points, startup wiring, and OS-specific integrations stay in platform-specific source sets
+- UI architecture does not assume Android-only behavior as the default model for shared code
+
+Flag as a concern when:
+- shared UI is duplicated across targets without a real platform difference
+- platform-specific assumptions leak into shared composables
+- common UI code depends on platform-only APIs
+
+## Review dimensions
+
+### 1. State-driven UI
+
+Compose guidance treats state management as foundational.
+
+Check whether:
+- UI renders from explicit immutable state
+- rendering is driven by state instead of imperative mutation
+- state ownership is clear
+- composables are not reaching into dependencies ad hoc
+
+Flag as a concern when:
+- composables fetch or mutate state from many unrelated places
+- UI is driven by scattered booleans rather than a coherent model
+- rendering depends on hidden mutable state
+
+### 2. State hoisting
+
+Compose explicitly documents state hoisting as a core design tool.
+
+Check whether:
+- state is hoisted when multiple composables need to coordinate it
+- local state remains local when it is truly local
+- app- or screen-level state is not trapped in leaf composables
+- callbacks and state ownership are easy to trace
+
+Flag as a concern when:
+- sibling composables coordinate through hidden shared state
+- screen-critical state lives in leaf components
+- hoisting is skipped and duplication appears across the tree
+
+### 3. State lifespans and saveability
+
+Compose docs distinguish state lifespans and saving UI state.
+
+Check whether:
+- ephemeral UI state is distinguished from longer-lived screen state
+- saveable UI state is used intentionally where needed
+- business-critical state is not confused with short-lived widget state
+
+**KMP platform note:** `rememberSaveable` with `Bundle`-based serialization is Android-specific. On iOS, desktop, and web KMP targets, the state-restoration mechanism differs or may not exist in the same form. If a project expects state restoration across process recreation on non-Android targets, verify that the chosen state-persistence mechanism is platform-appropriate rather than assuming `rememberSaveable` behavior is universal.
+
+Flag as a concern when:
+- important UI state is lost unnecessarily on any target
+- trivial widget state is elevated too far
+- long-lived state is modeled as disposable local widget state
+- `rememberSaveable` with Android-specific serializers is used in shared code without a non-Android fallback
+
+### 4. Side-effect discipline
+
+Compose explicitly treats side effects as a distinct design concern.
+
+Check whether:
+- side effects are isolated and deliberate
+- recomposition does not accidentally retrigger important actions
+- navigation, analytics, toasts, and one-off actions are not embedded casually in rendering branches
+- rendering stays as pure as practical
+
+Flag as a concern when:
+- side effects are triggered directly from unstable composable paths
+- repeated recomposition can duplicate work
+- business or navigation effects are hard to reason about
+
+### 5. UI architecture and layering
+
+Android’s Compose docs place UI architecture, architectural layering, and navigation alongside state guidance.
+
+Check whether:
+- composables focus on rendering and interaction
+- state holders or orchestration layers sit between UI and lower layers
+- business logic is not embedded in UI
+- UI models are shaped for presentation instead of mirroring raw transport/data models
+
+Flag as a concern when:
+- UI calls repositories directly
+- DTOs or persistence models reach rendering code directly
+- screen composables own orchestration, data parsing, and business rules inline
+
+### 6. Composable decomposition
+
+Check whether:
+- large screens are broken into meaningful subcomposables
+- reusable UI pieces are extracted where repetition exists
+- component boundaries are understandable
+- destination-level composables do not become monoliths
+
+Flag as a concern when:
+- one screen file owns all structure, state, and rendering inline
+- reusable visual patterns are duplicated broadly
+- composable boundaries do not reflect ownership clearly
+
+### 7. Layout structure
+
+Kotlin’s layout docs identify Rows, Columns, Boxes, lazy lists, and related primitives as the main layout building blocks.
+
+Check whether:
+- layout structure is readable
+- containers are chosen intentionally
+- layout nesting is not excessive without reason
+- lists and grids are used where scrolling collections exist
+- app shells and screen bodies remain understandable under growth
+
+Flag as a concern when:
+- layout structure is overly tangled
+- wrong container choices create brittle UI
+- scrolling/content structure is improvised instead of modeled clearly
+
+### 8. Modifier discipline
+
+Modifier order and combination matter.
+
+Check whether:
+- modifier chains are readable
+- modifier order is intentional
+- sizing, padding, click handling, offsets, scrolling, and semantics are combined in a way that is explainable
+- adaptive or interaction behavior is not hidden in long opaque chains
+
+Flag as a concern when:
+- modifier order causes accidental behavior
+- layout understanding depends on trial and error
+- giant modifier chains obscure ownership and behavior
+
+### 9. Adaptive layout readiness
+
+Kotlin's adaptive-layout docs support window-size-driven adaptation.
+
+This skill reviews whether shared UI is *structurally ready* for adaptive behavior — composed cleanly enough that adaptive variations can be added without a full rewrite. For deep guidance covering canonical layouts, multi-window support, list-detail patterns, supporting-pane behavior, and the full adaptive review framework, use the `kotlin-ui-adaptive-resources` skill.
+
+Check whether:
+- shared UI can adapt between compact, medium, and expanded layouts where appropriate
+- layout decisions can respond to window size rather than one fixed phone assumption
+- screen structure can evolve into multi-pane or wider layouts without a rewrite
+- adaptive concerns are considered early rather than retrofitted as an afterthought
+
+Flag as a concern when:
+- UI is designed only for one narrow window shape with no structural flexibility
+- larger layouts just stretch phone UI without meaningful structural adaptation
+- adaptation would require tearing apart the entire screen composable tree
+
+### 10. Window-size-driven UI decisions
+
+Adaptive docs provide structured size-class APIs (`rememberWindowAdaptiveInfo()`, `WindowWidthSizeClass`) for layout decisions. For full guidance on these APIs and canonical adaptive patterns, use the `kotlin-ui-adaptive-resources` skill. This section reviews structural readiness in shared UI.
+
+Check whether:
+- major shell/layout decisions can be expressed through structured window-size reasoning, not hardcoded dp breakpoints
+- adaptive branching is centralized enough to stay maintainable — one place to read window size, not scattered per-screen
+- width-driven changes preserve the same feature mental model across sizes
+
+Flag as a concern when:
+- arbitrary pixel/dp breakpoints are used instead of the structured size-class API
+- adaptive behavior differs inconsistently from screen to screen without a reason
+- layout branches are too fragmented to understand centrally
+### 11. Previewability
+
+Compose Multiplatform previews support `@Preview` in common code when configured with `ui-tooling-preview` in `commonMain`.
+
+Check whether:
+- reusable components are previewable independently
+- screen states can be previewed without full app bootstrapping
+- adaptive variants are previewed where useful
+- previews are practical enough to support iteration
+
+Flag as a concern when:
+- every UI change requires running the app
+- screens are too entangled to preview meaningfully
+- key visual states are difficult to inspect in isolation
+
+### 12. Accessibility and semantics
+
+Compose UI quality should account for semantics and user interaction clarity.
+
+Check whether:
+- content structure is understandable for accessibility services (TalkBack on Android, VoiceOver on iOS via Compose Multiplatform semantics bridge)
+- icon-only buttons and image-only interactive elements expose a `contentDescription`
+- `clearAndSetSemantics` is used intentionally — it removes all child semantics, which silences descendant content for screen readers
+- `semantics { }` merging behavior is explicit when composable groups are used as single logical units
+- focus ordering makes sense when adaptive layout rearranges components spatially
+- text contrast and touch target sizes remain acceptable across compact and expanded window states
+
+Flag as a concern when:
+- icon buttons or decorative-looking interactive elements have no `contentDescription`
+- `clearAndSetSemantics {}` is applied broadly without auditing what it silences
+- adaptive layout changes create focus traps or confusing reading order
+- visible structure and semantic structure drift apart (e.g., a single logical card reads as multiple unrelated elements)
+- semantics are omitted entirely from screens that change significantly with window size
+
+### 13. Shared-vs-platform UI boundary
+
+Check whether:
+- shared composables remain platform-agnostic
+- platform-specific startup, window integration, and OS hooks remain outside shared screen code
+- the common UI layer does not own Android/iOS entry responsibilities
+
+Flag as a concern when:
+- platform bootstrapping logic leaks into `commonMain`
+- shared UI depends on platform-specific APIs
+- platform-specific visuals or lifecycle details shape common UI unnecessarily
+
+### 14. Testability
+
+UI architecture should support testing and inspection.
+
+Check whether:
+- shared UI behavior can be validated in common tests where appropriate
+- composables are decomposed enough to test behavior or semantics
+- state-holder behavior is validated outside UI when possible
+- previewability and testability reinforce each other
+
+Flag as a concern when:
+- UI correctness can only be validated through full manual runs
+- composables are too monolithic to test meaningfully
+- state behavior is only inferred through large end-to-end paths
+
+---
+
+## Severity framework
+
+### High severity
+Likely to cause architectural drift or broken UI behavior.
+
+Examples:
+- business logic embedded in composables
+- no clear state owner
+- side effects triggered from unstable rendering paths
+- platform-only APIs in shared UI
+- UI built only for one narrow window size
+
+### Medium severity
+Workable, but likely to create maintenance cost.
+
+Examples:
+- weak state hoisting
+- oversized screen composables
+- tangled layout structure
+- modifier order causing brittle behavior
+- adaptive behavior present but inconsistent
+- poor previewability
+
+### Low severity
+Structurally acceptable but worth improving.
+
+Examples:
+- composable boundaries could be cleaner
+- preview coverage could be broader
+- modifier chains could be simplified
+- adaptive branches could be centralized more clearly
+
+---
+
+## Required output format
+
+When performing the review, respond with:
+
+1. **UI summary**
+   - state model
+   - composable structure
+   - layout/modifier approach
+   - adaptive strategy
+   - preview strategy
+   - shared/platform boundary
+
+2. **What is structurally sound**
+   - concrete strengths only
+
+3. **Issues by review dimension**
+   - state-driven UI
+   - state hoisting
+   - state lifespans/saveability
+   - side effects
+   - UI architecture/layering
+   - composable decomposition
+   - layout structure
+   - modifier discipline
+   - adaptive readiness
+   - window-size-driven decisions
+   - previewability
+   - accessibility/semantics
+   - shared-vs-platform boundary
+   - testability
+
+4. **Severity for each issue**
+   - high / medium / low
+
+5. **Concrete recommendations**
+   - exact restructuring steps
+   - what state should be hoisted
+   - what should move out of composables
+   - how layout/modifier structure should be simplified
+   - where adaptive branches should live
+   - what should become previewable/testable
+
+6. **Suggested target structure**
+   - proposed screen / component / state / preview split if useful
+
+7. **Open risks**
+   - migration cost
+   - visual/regression risk
+   - platform-specific constraints still to validate
+
+---
+
+## Tone
+
+Be direct and practical.
+Do not praise UI just because it renders.
+If the design is weak, say why clearly.
+
+---
+
+## Anti-patterns to flag aggressively
+
+- business logic in composables
+- hidden mutable state driving rendering
+- poor or absent state hoisting
+- side effects triggered from unstable composable paths
+- giant screen composables
+- long opaque modifier chains
+- layouts designed for one phone size only
+- shared UI coupled to platform-specific APIs
+- UI that cannot be previewed meaningfully
+- tests forced to validate simple UI behavior only through large integration paths
+
+---
+
+## References
+
+- Jetpack Compose documentation: https://developer.android.com/develop/ui/compose/documentation
+- Compose Multiplatform: Create your first app: https://kotlinlang.org/docs/multiplatform/compose-multiplatform-create-first-app.html
+- Compose Multiplatform: Layout basics: https://kotlinlang.org/docs/multiplatform/compose-layout.html
+- Compose Multiplatform: Modifiers: https://kotlinlang.org/docs/multiplatform/compose-layout-modifiers.html
+- Compose Multiplatform: Adaptive layouts: https://kotlinlang.org/docs/multiplatform/compose-adaptive-layouts.html
+- Compose Multiplatform: Previews: https://kotlinlang.org/docs/multiplatform/compose-previews.html
+- Android: Compose state and Jetpack: https://developer.android.com/develop/ui/compose/state
+- Android: Accessibility in Compose: https://developer.android.com/develop/ui/compose/accessibility

+ 96 - 0
.claude/skills/test-master/SKILL.md

@@ -0,0 +1,96 @@
+---
+name: test-master
+description: Generates test files, creates mocking strategies, analyzes code coverage, designs test architectures, and produces test plans and defect reports across functional, performance, and security testing disciplines. Use when writing unit tests, integration tests, or E2E tests; creating test strategies or automation frameworks; analyzing coverage gaps; performance testing with k6 or Artillery; security testing with OWASP methods; debugging flaky tests; or working on QA, regression, test automation, quality gates, shift-left testing, or test maintenance.
+license: MIT
+metadata:
+  author: https://github.com/Jeffallan
+  version: "1.1.1"
+  domain: quality
+  triggers: test, testing, QA, unit test, integration test, E2E, coverage, performance test, security test, regression, test strategy, test automation, test framework, quality metrics, defect, exploratory, usability, accessibility, localization, manual testing, shift-left, quality gate, flaky test, test maintenance
+  role: specialist
+  scope: testing
+  output-format: report
+  related-skills: fullstack-guardian, playwright-expert, devops-engineer, debugging-wizard, code-reviewer, feature-forge
+---
+
+# Test Master
+
+Comprehensive testing specialist ensuring software quality through functional, performance, and security testing.
+
+## Core Workflow
+
+1. **Define scope** — Identify what to test and which testing types apply
+2. **Create strategy** — Plan the test approach across functional, performance, and security perspectives
+3. **Write tests** — Implement tests with proper assertions (see example below)
+4. **Execute** — Run tests and collect results
+   - If tests fail: classify the failure (assertion error vs. environment/flakiness), fix root cause, re-run
+   - If tests are flaky: isolate ordering dependencies, check async handling, add retry or stabilization logic
+5. **Report** — Document findings with severity ratings and actionable fix recommendations
+   - Verify coverage targets are met before closing; flag gaps explicitly
+
+## Quick-Start Example
+
+A minimal Jest unit test illustrating the key patterns this skill enforces:
+
+```js
+// ✅ Good: meaningful description, specific assertion, isolated dependency
+describe('calculateDiscount', () => {
+  it('applies 10% discount for premium users', () => {
+    const result = calculateDiscount({ price: 100, userTier: 'premium' });
+    expect(result).toBe(90); // specific outcome, not just truthy
+  });
+
+  it('throws on negative price', () => {
+    expect(() => calculateDiscount({ price: -1, userTier: 'standard' }))
+      .toThrow('Price must be non-negative');
+  });
+});
+```
+
+Apply the same structure for pytest (`def test_…`, `assert result == expected`) and other frameworks.
+
+## Reference Guide
+
+Load detailed guidance based on context:
+
+<!-- TDD Iron Laws and Testing Anti-Patterns adapted from obra/superpowers by Jesse Vincent (@obra), MIT License -->
+
+| Topic | Reference | Load When |
+|-------|-----------|-----------|
+| Unit Testing | `references/unit-testing.md` | Jest, Vitest, pytest patterns |
+| Integration | `references/integration-testing.md` | API testing, Supertest |
+| E2E | `references/e2e-testing.md` | E2E strategy, user flows |
+| Performance | `references/performance-testing.md` | k6, load testing |
+| Security | `references/security-testing.md` | Security test checklist |
+| Reports | `references/test-reports.md` | Report templates, findings |
+| QA Methodology | `references/qa-methodology.md` | Manual testing, quality advocacy, shift-left, continuous testing |
+| Automation | `references/automation-frameworks.md` | Framework patterns, scaling, maintenance, team enablement |
+| TDD Iron Laws | `references/tdd-iron-laws.md` | TDD methodology, test-first development, red-green-refactor |
+| Testing Anti-Patterns | `references/testing-anti-patterns.md` | Test review, mock issues, test quality problems |
+
+## Constraints
+
+**MUST DO**
+- Test happy paths AND error/edge cases (e.g., empty input, null, boundary values)
+- Mock external dependencies — never call real APIs or databases in unit tests
+- Use meaningful `it('…')` descriptions that read as plain-English specifications
+- Assert specific outcomes (`expect(result).toBe(90)`), not just truthiness
+- Run tests in CI/CD; document and remediate coverage gaps
+
+**MUST NOT**
+- Skip error-path testing (e.g., don't test only the success branch of a try/catch)
+- Use production data in tests — use fixtures or factories instead
+- Create order-dependent tests — each test must be independently runnable
+- Ignore flaky tests — quarantine and fix them; don't just re-run until green
+- Test implementation details (internal method calls) — test observable behaviour
+
+## Output Templates
+
+When creating test plans, provide:
+1. Test scope and approach
+2. Test cases with expected outcomes
+3. Coverage analysis
+4. Findings with severity (Critical/High/Medium/Low)
+5. Specific fix recommendations
+
+[Documentation](https://jeffallan.github.io/claude-skills/skills/quality/test-master/)

+ 294 - 0
.claude/skills/test-master/references/automation-frameworks.md

@@ -0,0 +1,294 @@
+# Automation Frameworks
+
+## Advanced Framework Patterns
+
+### Screenplay Pattern
+```typescript
+// Better separation of concerns than POM
+export class Actor {
+  constructor(private page: Page) {}
+  attemptsTo(...tasks: Task[]) {
+    return Promise.all(tasks.map(t => t.performAs(this)));
+  }
+}
+
+class Login implements Task {
+  constructor(private email: string, private password: string) {}
+  async performAs(actor: Actor) {
+    await actor.page.getByLabel('Email').fill(this.email);
+    await actor.page.getByLabel('Password').fill(this.password);
+    await actor.page.getByRole('button', { name: 'Login' }).click();
+  }
+}
+
+// Clear, maintainable test code
+await new Actor(page).attemptsTo(new Login('user@test.com', 'pass'));
+```
+
+### Keyword-Driven Testing
+```typescript
+const keywords = {
+  NAVIGATE: (page, url) => page.goto(url),
+  CLICK: (page, selector) => page.click(selector),
+  TYPE: (page, selector, text) => page.fill(selector, text),
+  VERIFY: (page, selector) => expect(page.locator(selector)).toBeVisible(),
+};
+
+// Data drives execution - ideal for non-technical authors
+const steps = [
+  { keyword: 'NAVIGATE', args: ['/login'] },
+  { keyword: 'TYPE', args: ['#email', 'user@test.com'] },
+  { keyword: 'CLICK', args: ['#submit'] },
+];
+
+for (const step of steps) await keywords[step.keyword](page, ...step.args);
+```
+
+### Model-Based Testing
+```typescript
+// State machine defines valid transitions
+const cartModel = {
+  empty: { addItem: 'hasItems' },
+  hasItems: { addItem: 'hasItems', removeItem: 'hasItems|empty', checkout: 'checkingOut' },
+  checkingOut: { confirm: 'complete', cancel: 'hasItems' },
+};
+
+// Generate comprehensive test paths automatically
+const testPaths = generatePathsFromModel(cartModel);
+```
+
+## Maintenance Strategies
+
+### Self-Healing Locators
+```typescript
+// Multi-strategy finder with automatic fallback
+async function findElement(page: Page, strategies: string[]): Promise<Locator> {
+  for (const selector of strategies) {
+    const el = page.locator(selector);
+    if (await el.count() > 0) return el;
+  }
+  throw new Error(`Not found: ${strategies.join(', ')}`);
+}
+
+// Usage: tries best -> good -> fallback
+const submit = await findElement(page, [
+  '[data-testid="submit"]',     // Best: stable test ID
+  'button:has-text("Submit")',  // Good: semantic
+  'button.primary',             // Fallback: CSS
+]);
+```
+
+### Error Recovery & Smart Retry
+```typescript
+// Auto-retry with recovery actions
+async function clickWithRecovery(page: Page, selector: string, retries = 3) {
+  for (let i = 0; i < retries; i++) {
+    try {
+      await page.click(selector, { timeout: 5000 });
+      return;
+    } catch (e) {
+      if (i === retries - 1) throw e;
+      await page.reload();
+      await page.waitForLoadState('networkidle');
+    }
+  }
+}
+
+// Exponential backoff for flaky operations
+async function retryWithBackoff<T>(fn: () => Promise<T>, retries = 3): Promise<T> {
+  for (let i = 0; i < retries; i++) {
+    try {
+      return await fn();
+    } catch (e) {
+      if (i === retries - 1) throw e;
+      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)));
+    }
+  }
+}
+```
+
+## Scaling Strategies
+
+### Parallel & Distributed Execution
+```typescript
+// playwright.config.ts
+export default defineConfig({
+  workers: process.env.CI ? 8 : 4,
+  fullyParallel: true,
+  retries: process.env.CI ? 2 : 0,
+  
+  // Shard tests across multiple machines
+  shard: process.env.SHARD ? {
+    current: parseInt(process.env.SHARD_INDEX),
+    total: parseInt(process.env.SHARD_TOTAL),
+  } : undefined,
+});
+```
+
+```yaml
+# GitHub Actions: distribute across 5 workers
+strategy:
+  matrix:
+    shard: [1, 2, 3, 4, 5]
+steps:
+  - run: npx playwright test --shard=${{ matrix.shard }}/5
+```
+
+### Resource Optimization
+```typescript
+// Reuse browser contexts for faster execution
+let browser: Browser;
+let context: BrowserContext;
+
+test.beforeAll(async () => {
+  browser = await chromium.launch();
+  context = await browser.newContext();
+});
+
+test('test 1', async () => {
+  const page = await context.newPage();
+  // Test logic
+  await page.close();
+});
+
+test.afterAll(async () => {
+  await context.close();
+  await browser.close();
+});
+```
+
+## CI/CD Integration
+
+### Complete Pipeline
+```yaml
+name: E2E Tests
+on: [push, pull_request]
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        shard: [1, 2, 3, 4]
+    
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
+      - run: npm ci
+      - run: npx playwright install --with-deps
+      
+      - run: npx playwright test --shard=${{ matrix.shard }}/4
+        env:
+          CI: true
+      
+      - uses: actions/upload-artifact@v3
+        if: always()
+        with:
+          name: report-${{ matrix.shard }}
+          path: playwright-report/
+```
+
+### Test Data Factories
+```typescript
+export class UserFactory {
+  static create(overrides?: Partial<User>): User {
+    return {
+      id: faker.string.uuid(),
+      email: faker.internet.email(),
+      name: faker.person.fullName(),
+      role: 'user',
+      ...overrides,
+    };
+  }
+
+  static createMany(count: number) {
+    return Array.from({ length: count }, () => this.create());
+  }
+}
+
+// Seed test data
+test.beforeEach(async ({ page }) => {
+  await page.request.post('/api/test/seed', {
+    data: { users: UserFactory.createMany(10) },
+  });
+});
+```
+
+## Team Enablement
+
+### Training Program
+```markdown
+**Week 1-2**: Framework basics, page objects, first test
+**Week 3-4**: Data-driven, API integration, CI/CD
+**Week 5-6**: Performance, error handling, scaling
+**Ongoing**: Code reviews, knowledge sharing
+```
+
+### Code Review Checklist
+```markdown
+- [ ] Independent tests (no order dependency)
+- [ ] Semantic locators (getByRole, getByLabel)
+- [ ] Proper waits (no arbitrary timeouts)
+- [ ] Error cases tested
+- [ ] Test data cleanup
+- [ ] Meaningful test names
+- [ ] Page objects updated
+```
+
+## Automation Strategy
+
+### ROI Calculation
+```typescript
+const manual = { timePerRun: 30, runsPerSprint: 10 };
+const automation = { development: 120, maintenance: 5 };
+
+const timeSaved = (manual.timePerRun * manual.runsPerSprint) - automation.maintenance;
+const breakEven = Math.ceil(automation.development / timeSaved);
+const annualSavings = (timeSaved * 26 - automation.development) / 60; // hours
+
+// Example: Break-even in 1 sprint, save 110 hours/year
+```
+
+### Selection Criteria
+```markdown
+**Automate**: Repetitive, stable UI, critical paths, data-driven, positive ROI
+**Don't Automate**: Exploratory, changing UI, one-time, usability, negative ROI
+```
+
+## Reporting & Metrics
+
+### Custom Reporter
+```typescript
+class MetricsReporter implements Reporter {
+  onTestEnd(test: TestCase, result: TestResult) {
+    this.sendMetrics({
+      name: test.title,
+      duration: result.duration,
+      status: result.status,
+      retries: result.retry,
+    });
+  }
+}
+```
+
+## Quick Reference
+
+| Pattern | Best For | Complexity |
+|---------|----------|-----------|
+| Page Object | Reusable components | Medium |
+| Screenplay | Complex workflows | High |
+| Keyword-Driven | Non-tech testers | Low |
+| Model-Based | State machines | High |
+
+| Scaling | Use Case |
+|---------|----------|
+| Parallel | Reduce time |
+| Distributed | Large suites |
+| Cloud | Cross-browser |
+| Resource Reuse | Speed |
+
+| Tool | Category |
+|------|----------|
+| Playwright, Cypress | Web E2E |
+| Appium, Detox | Mobile |
+| k6, Gatling | Performance |

+ 128 - 0
.claude/skills/test-master/references/e2e-testing.md

@@ -0,0 +1,128 @@
+# E2E Testing
+
+## E2E Test Strategy
+
+```typescript
+// Critical user paths to test
+const criticalPaths = [
+  'User registration and login',
+  'Core product/service workflow',
+  'Payment/checkout flow',
+  'Settings and profile management',
+];
+```
+
+## User Flow Testing
+
+```typescript
+import { test, expect } from '@playwright/test';
+
+test.describe('User Registration Flow', () => {
+  test('complete registration', async ({ page }) => {
+    await page.goto('/register');
+
+    await page.getByLabel('Email').fill('new@example.com');
+    await page.getByLabel('Password').fill('SecurePass123!');
+    await page.getByLabel('Confirm Password').fill('SecurePass123!');
+    await page.getByRole('button', { name: 'Register' }).click();
+
+    await expect(page).toHaveURL(/dashboard/);
+    await expect(page.getByText('Welcome')).toBeVisible();
+  });
+
+  test('shows validation errors', async ({ page }) => {
+    await page.goto('/register');
+
+    await page.getByLabel('Email').fill('invalid');
+    await page.getByRole('button', { name: 'Register' }).click();
+
+    await expect(page.getByText('Invalid email')).toBeVisible();
+  });
+});
+```
+
+## Checkout Flow
+
+```typescript
+test.describe('Checkout Flow', () => {
+  test('complete purchase', async ({ page }) => {
+    // Add to cart
+    await page.goto('/products/123');
+    await page.getByRole('button', { name: 'Add to Cart' }).click();
+    await expect(page.getByTestId('cart-count')).toHaveText('1');
+
+    // Checkout
+    await page.goto('/cart');
+    await page.getByRole('button', { name: 'Checkout' }).click();
+
+    // Payment
+    await page.getByLabel('Card Number').fill('4242424242424242');
+    await page.getByLabel('Expiry').fill('12/25');
+    await page.getByLabel('CVC').fill('123');
+    await page.getByRole('button', { name: 'Pay' }).click();
+
+    // Confirmation
+    await expect(page).toHaveURL(/order-confirmation/);
+    await expect(page.getByText('Order Confirmed')).toBeVisible();
+  });
+});
+```
+
+## Test Data Management
+
+```typescript
+// fixtures/testData.ts
+export const testUsers = {
+  standard: {
+    email: 'standard@test.com',
+    password: 'TestPass123!',
+  },
+  admin: {
+    email: 'admin@test.com',
+    password: 'AdminPass123!',
+  },
+};
+
+// Test setup
+test.beforeEach(async ({ page }) => {
+  // Seed test data
+  await page.request.post('/api/test/seed');
+});
+
+test.afterEach(async ({ page }) => {
+  // Clean up
+  await page.request.post('/api/test/cleanup');
+});
+```
+
+## Cross-Browser Testing
+
+```typescript
+// playwright.config.ts
+export default defineConfig({
+  projects: [
+    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
+    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
+    { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
+    { name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
+  ],
+});
+```
+
+## Quick Reference
+
+| Pattern | When to Use |
+|---------|-------------|
+| Happy path | Critical user journeys |
+| Error handling | Form validation, API errors |
+| Edge cases | Empty states, max limits |
+| Cross-browser | Before major releases |
+| Mobile | Responsive features |
+
+| Priority | Test Coverage |
+|----------|---------------|
+| **P0** | Registration, login, core feature |
+| **P1** | Payment, settings, common flows |
+| **P2** | Edge cases, admin features |
+| **P3** | Rare scenarios |

+ 120 - 0
.claude/skills/test-master/references/integration-testing.md

@@ -0,0 +1,120 @@
+# Integration Testing
+
+## API Testing (Supertest)
+
+```typescript
+import request from 'supertest';
+import { app } from '../app';
+
+describe('POST /api/users', () => {
+  it('creates user with valid data', async () => {
+    const response = await request(app)
+      .post('/api/users')
+      .send({ email: 'test@test.com', name: 'Test' })
+      .expect(201);
+
+    expect(response.body).toMatchObject({
+      email: 'test@test.com',
+      name: 'Test',
+    });
+    expect(response.body.id).toBeDefined();
+  });
+
+  it('returns 400 for invalid email', async () => {
+    const response = await request(app)
+      .post('/api/users')
+      .send({ email: 'invalid', name: 'Test' })
+      .expect(400);
+
+    expect(response.body.error).toContain('email');
+  });
+
+  it('returns 401 without auth token', async () => {
+    await request(app)
+      .get('/api/users/me')
+      .expect(401);
+  });
+});
+```
+
+## Authenticated Requests
+
+```typescript
+describe('Protected endpoints', () => {
+  let authToken: string;
+
+  beforeAll(async () => {
+    const response = await request(app)
+      .post('/api/auth/login')
+      .send({ email: 'test@test.com', password: 'password' });
+    authToken = response.body.token;
+  });
+
+  it('accesses protected route', async () => {
+    await request(app)
+      .get('/api/users/me')
+      .set('Authorization', `Bearer ${authToken}`)
+      .expect(200);
+  });
+});
+```
+
+## Database Testing
+
+```typescript
+import { db } from '../database';
+
+describe('UserRepository', () => {
+  beforeEach(async () => {
+    await db.query('DELETE FROM users');
+  });
+
+  afterAll(async () => {
+    await db.end();
+  });
+
+  it('creates and retrieves user', async () => {
+    const user = await userRepo.create({
+      email: 'test@test.com',
+      name: 'Test',
+    });
+
+    const found = await userRepo.findById(user.id);
+    expect(found).toEqual(user);
+  });
+});
+```
+
+## pytest API Testing
+
+```python
+import pytest
+from httpx import AsyncClient
+
+@pytest.mark.asyncio
+async def test_create_user(client: AsyncClient):
+    response = await client.post("/api/users/", json={
+        "email": "test@example.com",
+        "name": "Test"
+    })
+    assert response.status_code == 201
+    assert response.json()["email"] == "test@example.com"
+
+@pytest.mark.asyncio
+async def test_invalid_email(client: AsyncClient):
+    response = await client.post("/api/users/", json={
+        "email": "invalid",
+        "name": "Test"
+    })
+    assert response.status_code == 422
+```
+
+## Quick Reference
+
+| Method | Purpose |
+|--------|---------|
+| `.send(body)` | Send request body |
+| `.set(header, value)` | Set header |
+| `.expect(status)` | Assert status code |
+| `.expect('Content-Type', /json/)` | Assert header |
+| `response.body` | Parsed JSON body |

+ 118 - 0
.claude/skills/test-master/references/performance-testing.md

@@ -0,0 +1,118 @@
+# Performance Testing
+
+## k6 Load Test
+
+```javascript
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+
+export const options = {
+  stages: [
+    { duration: '30s', target: 20 },   // Ramp up to 20 users
+    { duration: '1m', target: 20 },    // Stay at 20 users
+    { duration: '30s', target: 0 },    // Ramp down
+  ],
+  thresholds: {
+    http_req_duration: ['p(95)<500'],  // 95% requests under 500ms
+    http_req_failed: ['rate<0.01'],    // <1% errors
+  },
+};
+
+export default function () {
+  const res = http.get('http://localhost:3000/api/users');
+
+  check(res, {
+    'status is 200': (r) => r.status === 200,
+    'response time < 200ms': (r) => r.timings.duration < 200,
+  });
+
+  sleep(1);
+}
+```
+
+## Stress Test
+
+```javascript
+export const options = {
+  stages: [
+    { duration: '2m', target: 100 },   // Ramp to 100 users
+    { duration: '5m', target: 100 },   // Stay at 100
+    { duration: '2m', target: 200 },   // Push to 200
+    { duration: '5m', target: 200 },   // Stay at 200
+    { duration: '2m', target: 0 },     // Ramp down
+  ],
+};
+```
+
+## Spike Test
+
+```javascript
+export const options = {
+  stages: [
+    { duration: '10s', target: 10 },   // Normal load
+    { duration: '1m', target: 10 },
+    { duration: '10s', target: 200 },  // Spike!
+    { duration: '3m', target: 200 },
+    { duration: '10s', target: 10 },   // Scale down
+    { duration: '3m', target: 10 },
+    { duration: '10s', target: 0 },
+  ],
+};
+```
+
+## API Testing with Auth
+
+```javascript
+import http from 'k6/http';
+
+export function setup() {
+  const loginRes = http.post('http://localhost:3000/api/login', {
+    email: 'test@test.com',
+    password: 'password',
+  });
+  return { token: loginRes.json('token') };
+}
+
+export default function (data) {
+  const params = {
+    headers: { Authorization: `Bearer ${data.token}` },
+  };
+
+  http.get('http://localhost:3000/api/protected', params);
+}
+```
+
+## Thresholds Reference
+
+```javascript
+thresholds: {
+  // Response time
+  http_req_duration: ['p(95)<500', 'p(99)<1000'],
+
+  // Error rate
+  http_req_failed: ['rate<0.01'],
+
+  // Throughput
+  http_reqs: ['rate>100'],
+
+  // Custom metrics
+  'http_req_duration{name:login}': ['p(95)<200'],
+}
+```
+
+## Quick Reference
+
+| Metric | Description |
+|--------|-------------|
+| `http_req_duration` | Response time |
+| `http_req_failed` | Failed requests rate |
+| `http_reqs` | Request rate |
+| `p(95)` | 95th percentile |
+| `rate` | Rate per second |
+
+| Test Type | Purpose |
+|-----------|---------|
+| Load | Normal expected load |
+| Stress | Find breaking point |
+| Spike | Sudden traffic surge |
+| Soak | Long duration stability |

+ 247 - 0
.claude/skills/test-master/references/qa-methodology.md

@@ -0,0 +1,247 @@
+# QA Methodology
+
+## Manual Testing Types
+
+### Exploratory Testing
+```markdown
+**Charter**: Explore {feature} with focus on {aspect}
+**Duration**: 60-90 min
+**Mission**: Find defects in {specific functionality}
+
+Test Ideas:
+- Boundary conditions & edge cases
+- Error handling & recovery
+- User workflow variations
+- Integration points
+
+Findings:
+1. [HIGH] {Issue + impact}
+2. [MED] {Issue + impact}
+
+Coverage: {Areas explored} | Risks: {Identified risks}
+```
+
+### Usability Testing
+```markdown
+**Task**: Can users complete {action} intuitively?
+**Metrics**: Time to complete, errors made, satisfaction (1-5)
+**Success**: 80% complete without help in <5 min
+
+Observations:
+- Navigation confusing at {step}
+- Users expect {A} but get {B}
+- Positive: {feature feedback}
+```
+
+### Accessibility Testing (WCAG 2.1 AA)
+```typescript
+test('accessibility compliance', async ({ page }) => {
+  // Keyboard navigation
+  await page.keyboard.press('Tab');
+  expect(['A', 'BUTTON', 'INPUT']).toContain(
+    await page.evaluate(() => document.activeElement.tagName)
+  );
+  
+  // ARIA labels
+  expect(await page.getByRole('button').first().getAttribute('aria-label')).toBeTruthy();
+  
+  // Color contrast (axe-core)
+  const violations = await page.evaluate(async () => {
+    const axe = await import('axe-core');
+    return (await axe.run()).violations;
+  });
+  expect(violations).toHaveLength(0);
+});
+```
+
+### Localization Testing
+```markdown
+**Test**: {Feature} in {language/locale}
+- [ ] Text displays without truncation
+- [ ] Date/time/currency formats correct
+- [ ] Right-to-left layout (Arabic, Hebrew)
+- [ ] Character encoding UTF-8
+- [ ] Sort order respects locale
+```
+
+### Compatibility Matrix
+```markdown
+| Browser | Version | OS | Status |
+|---------|---------|----|----- --|
+| Chrome | Latest | Win/Mac | ✓ |
+| Firefox | Latest | Win/Mac | ✓ |
+| Safari | Latest | macOS/iOS | ✓ |
+| Edge | Latest | Windows | ✓ |
+```
+
+## Test Design Techniques
+
+### Pairwise Testing
+```typescript
+// Test all parameter pairs efficiently
+const pairwiseTests = [
+  { browser: 'chrome', os: 'windows', lang: 'en' },
+  { browser: 'firefox', os: 'mac', lang: 'es' },
+  { browser: 'safari', os: 'windows', lang: 'fr' },
+  // Covers all pairs with minimal tests
+];
+```
+
+### Risk-Based Testing
+```markdown
+| Risk | Probability | Impact | Priority | Test Effort |
+|------|-------------|--------|----------|-------------|
+| Critical | High | High | P0 | Exhaustive |
+| High | Med-High | High | P1 | Comprehensive |
+| Medium | Low-Med | Med | P2 | Standard |
+| Low | Low | Low | P3 | Smoke only |
+```
+
+## Defect Management
+
+### Root Cause Analysis (5 Whys)
+```markdown
+1. Why did defect occur? {User input not validated}
+2. Why wasn't it validated? {Validation logic missing}
+3. Why was it missing? {Requirement unclear}
+4. Why was requirement unclear? {Acceptance criteria incomplete}
+5. Why incomplete? {No QA review in planning}
+
+**Root Cause**: QA not involved in requirements phase
+**Prevention**: Add QA to all planning meetings
+```
+
+### Defect Report Template
+```markdown
+## [CRITICAL] {Defect Title}
+
+**Steps to Reproduce**:
+1. {Step 1}
+2. {Step 2}
+
+**Expected**: {Should happen}
+**Actual**: {Actually happens}
+**Impact**: {Business/user impact}
+**Root Cause**: {Why it happened}
+**Fix**: {Recommended solution}
+```
+
+## Quality Metrics
+
+### Key Calculations
+```typescript
+// Defect Removal Efficiency (target: >95%)
+const dre = (defectsInTesting / (defectsInTesting + defectsInProd)) * 100;
+
+// Defect Leakage (target: <5%)
+const leakage = (defectsInProd / totalDefects) * 100;
+
+// Test Effectiveness (target: >90%)
+const effectiveness = (defectsFoundByTests / totalDefects) * 100;
+
+// Automation ROI
+const roi = (timeSaved - maintenanceCost - developmentCost) / developmentCost;
+```
+
+### Quality Dashboard
+```markdown
+| Metric | Target | Actual | Trend | Status |
+|--------|--------|--------|-------|--------|
+| Coverage | >80% | 87% | ↑ | ✓ |
+| Defect Leakage | <5% | 3% | ↓ | ✓ |
+| Automation | >70% | 68% | ↑ | ⚠ |
+| Critical Defects | 0 | 0 | → | ✓ |
+| MTTR | <48h | 36h | ↓ | ✓ |
+```
+
+## Continuous Testing & Shift-Left
+
+### Shift-Left Activities
+```markdown
+**Early Testing**:
+- Review requirements for testability
+- Create test cases during design
+- TDD: unit tests with code
+- Automated tests in CI pipeline
+- Static analysis on commit
+- Security scanning pre-merge
+
+**Benefits**: 10x cheaper defect fixes, faster feedback
+```
+
+### Feedback Cycle Targets
+```typescript
+const feedbackCycle = {
+  unitTests: '< 5 min',       // On save
+  integration: '< 15 min',    // On commit
+  e2e: '< 30 min',            // On PR
+  regression: '< 2 hours',    // Nightly
+};
+```
+
+## Quality Advocacy
+
+### Quality Gates
+```markdown
+## Production Release Gate
+
+**Must Pass (Blockers)**:
+- [ ] Zero critical defects
+- [ ] Coverage >80%
+- [ ] All P0/P1 tests passing
+- [ ] Performance SLA met
+- [ ] Security scan clean
+- [ ] Accessibility WCAG AA
+
+**Decision**: GO | NO-GO | GO with exceptions
+```
+
+### Team Education Program
+```markdown
+**Week 1-2**: Test fundamentals
+**Week 3-4**: Automation basics
+**Week 5-6**: Advanced topics (perf, security, API)
+**Ongoing**: Best practices, tool updates
+```
+
+## Test Planning
+
+### Test Plan Template
+```markdown
+## Test Plan: {Feature}
+
+**Scope**: {What to test}
+**Types**: Unit, Integration, E2E, Perf, Security
+**Resources**: {Team allocation}
+**Dependencies**: {Prerequisites}
+**Schedule**: {Timeline}
+**Entry Criteria**: {Start conditions}
+**Exit Criteria**: {Completion conditions}
+**Risks**: {Identified risks + mitigation}
+```
+
+### Environment Strategy
+```markdown
+| Env | Purpose | Data | Refresh | Access |
+|-----|---------|------|---------|--------|
+| Dev | Development | Synthetic | On-demand | All |
+| Test | QA testing | Test data | Daily | QA |
+| Stage | Pre-prod | Prod-like | Weekly | Limited |
+| Prod | Live | Real | N/A | Ops |
+```
+
+## Quick Reference
+
+| Testing Type | When | Duration |
+|--------------|------|----------|
+| Exploratory | New features | 60-120 min |
+| Usability | UI changes | 2-4 hours |
+| Accessibility | Every release | 1-2 hours |
+| Localization | Multi-region | 1 day/locale |
+
+| Metric | Excellent | Good | Needs Work |
+|--------|-----------|------|------------|
+| Coverage | >90% | 70-90% | <70% |
+| Leakage | <2% | 2-5% | >5% |
+| Automation | >80% | 60-80% | <60% |
+| MTTR | <24h | 24-48h | >48h |

+ 127 - 0
.claude/skills/test-master/references/security-testing.md

@@ -0,0 +1,127 @@
+# Security Testing
+
+## Authentication Tests
+
+```typescript
+describe('Authentication Security', () => {
+  it('rejects invalid credentials', async () => {
+    await request(app)
+      .post('/api/login')
+      .send({ email: 'user@test.com', password: 'wrong' })
+      .expect(401);
+  });
+
+  it('rejects expired tokens', async () => {
+    const expiredToken = createExpiredToken();
+    await request(app)
+      .get('/api/protected')
+      .set('Authorization', `Bearer ${expiredToken}`)
+      .expect(401);
+  });
+
+  it('rejects tampered tokens', async () => {
+    const tamperedToken = validToken.slice(0, -5) + 'xxxxx';
+    await request(app)
+      .get('/api/protected')
+      .set('Authorization', `Bearer ${tamperedToken}`)
+      .expect(401);
+  });
+
+  it('enforces rate limiting on login', async () => {
+    for (let i = 0; i < 6; i++) {
+      await request(app)
+        .post('/api/login')
+        .send({ email: 'user@test.com', password: 'wrong' });
+    }
+
+    await request(app)
+      .post('/api/login')
+      .send({ email: 'user@test.com', password: 'correct' })
+      .expect(429);
+  });
+});
+```
+
+## Authorization Tests
+
+```typescript
+describe('Authorization', () => {
+  it('denies access to other users resources', async () => {
+    await request(app)
+      .get('/api/users/other-user-id/data')
+      .set('Authorization', `Bearer ${userAToken}`)
+      .expect(403);
+  });
+
+  it('denies admin routes to regular users', async () => {
+    await request(app)
+      .delete('/api/admin/users/123')
+      .set('Authorization', `Bearer ${regularUserToken}`)
+      .expect(403);
+  });
+});
+```
+
+## Input Validation Tests
+
+```typescript
+describe('Input Validation', () => {
+  it('rejects SQL injection attempts', async () => {
+    await request(app)
+      .get('/api/users')
+      .query({ search: "'; DROP TABLE users; --" })
+      .expect(400);
+  });
+
+  it('rejects XSS in input fields', async () => {
+    const response = await request(app)
+      .post('/api/posts')
+      .send({ title: '<script>alert("xss")</script>' })
+      .expect(201);
+
+    expect(response.body.title).not.toContain('<script>');
+  });
+
+  it('validates file upload types', async () => {
+    await request(app)
+      .post('/api/upload')
+      .attach('file', 'malicious.exe')
+      .expect(400);
+  });
+});
+```
+
+## Security Headers Test
+
+```typescript
+describe('Security Headers', () => {
+  it('sets security headers', async () => {
+    const response = await request(app).get('/');
+
+    expect(response.headers['x-content-type-options']).toBe('nosniff');
+    expect(response.headers['x-frame-options']).toBe('DENY');
+    expect(response.headers['strict-transport-security']).toBeDefined();
+  });
+});
+```
+
+## Security Test Checklist
+
+| Category | Tests |
+|----------|-------|
+| **Auth** | Invalid creds, token expiry, tampering |
+| **Input** | SQL injection, XSS, command injection |
+| **Access** | IDOR, privilege escalation |
+| **Rate Limit** | Brute force, API abuse |
+| **Headers** | CSP, HSTS, X-Frame-Options |
+| **Data** | PII exposure, error messages |
+
+## Quick Reference
+
+| Vulnerability | Test Approach |
+|---------------|---------------|
+| SQL Injection | `'; DROP TABLE--` in inputs |
+| XSS | `<script>alert(1)</script>` |
+| IDOR | Access other user's resources |
+| CSRF | Missing/invalid tokens |
+| Auth Bypass | Missing auth, expired tokens |

+ 174 - 0
.claude/skills/test-master/references/tdd-iron-laws.md

@@ -0,0 +1,174 @@
+# TDD Iron Laws
+
+---
+
+## The Fundamental Principle
+
+> **NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST.**
+
+This is non-negotiable. If you wrote production code before writing a failing test, delete it and start over. No exceptions.
+
+---
+
+## The Three Iron Laws
+
+### Iron Law 1: The Fundamental Rule
+
+> "You shall not write any production code unless it is to make a failing test pass."
+
+Every line of production code must have a corresponding test that:
+1. Was written first
+2. Was observed to fail
+3. Now passes because of that code
+
+### Iron Law 2: Proof Through Observation
+
+> "If you didn't watch the test fail, you don't know if it tests the right thing."
+
+Mandatory verification steps:
+- Write the test
+- Run it and **observe the failure**
+- Verify the failure message is meaningful
+- Only then implement the fix
+
+A test you've never seen fail proves nothing.
+
+### Iron Law 3: The Final Rule
+
+> "Production code exists → A test exists that failed first. Otherwise → It's not TDD."
+
+There is no middle ground. Code written without a prior failing test is not test-driven development, regardless of how many tests exist afterward.
+
+---
+
+## The RED-GREEN-REFACTOR Cycle
+
+### RED: Write One Minimal Failing Test
+
+```typescript
+// Start with the smallest possible failing test
+it('should return 0 for empty array', () => {
+  expect(sum([])).toBe(0);
+});
+// Run: ✗ FAIL - sum is not defined
+```
+
+**Requirements:**
+- One test at a time
+- Minimal scope
+- Clear failure message
+- Observe the red
+
+### GREEN: Implement Simplest Passing Code
+
+```typescript
+// Write only enough code to pass this specific test
+function sum(numbers: number[]): number {
+  return 0;
+}
+// Run: ✓ PASS
+```
+
+**Requirements:**
+- Simplest possible implementation
+- No extra features
+- No optimization
+- Just make it pass
+
+### REFACTOR: Improve While Keeping Tests Green
+
+```typescript
+// Now improve the code while tests stay green
+function sum(numbers: number[]): number {
+  return numbers.reduce((acc, n) => acc + n, 0);
+}
+// Run: ✓ PASS (still)
+```
+
+**Requirements:**
+- Tests must stay green
+- Remove duplication
+- Improve clarity
+- No new functionality
+
+---
+
+## Common Rationalizations to Reject
+
+These thoughts indicate you're about to violate TDD:
+
+| Rationalization | Why It's Wrong |
+|-----------------|----------------|
+| "I can manually test this quickly" | Manual testing doesn't prevent regression |
+| "I'll write tests after to save time" | You'll skip edge cases and test implementation |
+| "This is too simple to need a test" | Simple code changes; tests document expectations |
+| "I've already written the code, I can't delete it now" | Sunk cost fallacy; delete it |
+| "I know this works, I've done it before" | Your memory isn't documentation |
+| "We're in a hurry" | Technical debt costs more than TDD |
+
+---
+
+## Practical Application
+
+### Starting a New Feature
+
+```typescript
+// 1. RED: Write failing test for simplest behavior
+describe('UserValidator', () => {
+  it('should reject empty email', () => {
+    expect(validateEmail('')).toBe(false);
+  });
+});
+
+// 2. GREEN: Implement minimal passing code
+function validateEmail(email: string): boolean {
+  return email.length > 0;
+}
+
+// 3. RED: Add next failing test
+it('should reject email without @', () => {
+  expect(validateEmail('invalid')).toBe(false);
+});
+
+// 4. GREEN: Extend to pass both tests
+function validateEmail(email: string): boolean {
+  return email.length > 0 && email.includes('@');
+}
+
+// Continue cycle...
+```
+
+### Fixing a Bug
+
+```typescript
+// 1. RED: Write test that exposes the bug
+it('should handle negative numbers in sum', () => {
+  expect(sum([-1, -2, -3])).toBe(-6);
+});
+// Run: ✗ FAIL - got 0 instead of -6
+
+// 2. GREEN: Fix the bug
+function sum(numbers: number[]): number {
+  return numbers.reduce((acc, n) => acc + n, 0);
+}
+// Run: ✓ PASS
+
+// Bug is now fixed AND protected against regression
+```
+
+---
+
+## Verification Checklist
+
+Before claiming any code is complete:
+
+- [ ] Every production function has corresponding tests
+- [ ] Each test was written before its implementation
+- [ ] Each test was observed to fail first
+- [ ] Tests verify behavior, not implementation
+- [ ] Refactoring kept all tests green
+- [ ] No production code exists without a test
+
+---
+
+*Content adapted from [obra/superpowers](https://github.com/obra/superpowers) by Jesse Vincent (@obra), MIT License.*

+ 104 - 0
.claude/skills/test-master/references/test-reports.md

@@ -0,0 +1,104 @@
+# Test Reports
+
+## Test Report Template
+
+```markdown
+# Test Report: {Feature Name}
+
+**Date**: YYYY-MM-DD
+**Tester**: {Name}
+**Version**: {App Version}
+
+## Summary
+
+| Metric | Value |
+|--------|-------|
+| Total Tests | X |
+| Passed | X |
+| Failed | X |
+| Skipped | X |
+| Coverage | X% |
+
+## Test Scope
+
+- [x] Unit tests
+- [x] Integration tests
+- [x] E2E tests
+- [ ] Performance tests
+- [ ] Security tests
+
+## Findings
+
+### [CRITICAL] {Issue Title}
+- **Location**: src/api/users.ts:45
+- **Steps to Reproduce**:
+  1. Send POST to /api/users without auth
+  2. Request succeeds with 201
+- **Expected**: 401 Unauthorized
+- **Actual**: 201 Created
+- **Impact**: Unauthorized user creation
+- **Fix**: Add auth middleware
+
+### [HIGH] {Issue Title}
+- **Location**: src/services/orders.ts:123
+- **Description**: N+1 query in order list
+- **Impact**: 3s response time with 100 orders
+- **Fix**: Add eager loading for order items
+
+### [MEDIUM] {Issue Title}
+- **Details**: ...
+
+### [LOW] {Issue Title}
+- **Details**: ...
+
+## Coverage Analysis
+
+| Module | Lines | Branches | Functions |
+|--------|-------|----------|-----------|
+| api/ | 85% | 78% | 90% |
+| services/ | 92% | 85% | 95% |
+| utils/ | 100% | 100% | 100% |
+
+### Coverage Gaps
+- `src/api/admin.ts` - 0% (no tests)
+- `src/services/payment.ts:45-60` - Error handling untested
+
+## Recommendations
+
+1. **Immediate**: Add auth middleware to admin routes
+2. **High Priority**: Optimize order queries
+3. **Medium Priority**: Add tests for payment error handling
+4. **Low Priority**: Increase branch coverage in api/
+
+## Performance Results
+
+| Endpoint | p50 | p95 | p99 |
+|----------|-----|-----|-----|
+| GET /users | 45ms | 120ms | 250ms |
+| POST /orders | 150ms | 400ms | 800ms |
+
+## Sign-off
+
+- [ ] All critical issues addressed
+- [ ] Coverage meets threshold (80%)
+- [ ] Performance meets SLA
+```
+
+## Severity Definitions
+
+| Severity | Criteria |
+|----------|----------|
+| **CRITICAL** | Security vulnerability, data loss, system crash |
+| **HIGH** | Major functionality broken, severe performance |
+| **MEDIUM** | Feature partially working, workaround exists |
+| **LOW** | Minor issue, cosmetic, edge case |
+
+## Quick Reference
+
+| Section | Content |
+|---------|---------|
+| Summary | High-level metrics |
+| Findings | Issues by severity |
+| Coverage | Code coverage analysis |
+| Recommendations | Prioritized actions |
+| Sign-off | Approval criteria |

+ 231 - 0
.claude/skills/test-master/references/testing-anti-patterns.md

@@ -0,0 +1,231 @@
+# Testing Anti-Patterns
+
+---
+
+## Core Principle
+
+> **"Test what the code does, not what the mocks do."**
+
+When tests verify mock behavior instead of actual functionality, they provide false confidence while catching zero real bugs.
+
+---
+
+## The Five Anti-Patterns
+
+### Anti-Pattern 1: Testing Mock Behavior
+
+**The Problem:** Verifying that mocks exist and were called, rather than testing actual component output.
+
+```typescript
+// ❌ BAD: Testing the mock, not the behavior
+it('should call the API', () => {
+  const mockApi = jest.fn().mockResolvedValue({ data: 'test' });
+  const service = new UserService(mockApi);
+
+  service.getUser(1);
+
+  expect(mockApi).toHaveBeenCalledWith(1); // Testing mock, not result
+});
+```
+
+```typescript
+// ✅ GOOD: Testing actual behavior
+it('should return user data from API', async () => {
+  const mockApi = jest.fn().mockResolvedValue({ id: 1, name: 'Alice' });
+  const service = new UserService(mockApi);
+
+  const user = await service.getUser(1);
+
+  expect(user.name).toBe('Alice'); // Testing actual output
+});
+```
+
+**Solution:** Test the genuine component output. If you can only verify mock calls, reconsider whether the test adds value.
+
+---
+
+### Anti-Pattern 2: Test-Only Methods in Production
+
+**The Problem:** Adding methods to production classes solely for test setup or cleanup.
+
+```typescript
+// ❌ BAD: Production code polluted with test concerns
+class UserCache {
+  private cache: Map<number, User> = new Map();
+
+  getUser(id: number): User | undefined {
+    return this.cache.get(id);
+  }
+
+  // This method exists ONLY for tests
+  _resetForTesting(): void {
+    this.cache.clear();
+  }
+}
+```
+
+```typescript
+// ✅ GOOD: Test utilities separate from production
+// production/UserCache.ts
+class UserCache {
+  private cache: Map<number, User> = new Map();
+
+  getUser(id: number): User | undefined {
+    return this.cache.get(id);
+  }
+}
+
+// test/helpers.ts
+function createFreshCache(): UserCache {
+  return new UserCache(); // Fresh instance per test
+}
+```
+
+**Solution:** Relocate cleanup logic to test utility functions. Use fresh instances per test instead of reset methods.
+
+---
+
+### Anti-Pattern 3: Mocking Without Understanding
+
+**The Problem:** Over-mocking without grasping side effects, leading to tests that pass but hide real issues.
+
+```typescript
+// ❌ BAD: Mocking everything without understanding
+it('should process order', async () => {
+  jest.mock('./inventory');
+  jest.mock('./payment');
+  jest.mock('./shipping');
+  jest.mock('./notifications');
+
+  const result = await processOrder(order);
+
+  expect(result.success).toBe(true); // What did we actually test?
+});
+```
+
+```typescript
+// ✅ GOOD: Strategic mocking with real components where possible
+it('should process order with real inventory check', async () => {
+  // Real inventory service against test database
+  const inventory = new InventoryService(testDb);
+
+  // Mock only external services
+  const payment = mockPaymentGateway();
+
+  const processor = new OrderProcessor(inventory, payment);
+  const result = await processor.process(order);
+
+  expect(result.success).toBe(true);
+  expect(await inventory.getStock(order.itemId)).toBe(originalStock - 1);
+});
+```
+
+**Solution:** Run tests with real implementations first to understand behavior. Then mock at the appropriate level - external services, not internal logic.
+
+---
+
+### Anti-Pattern 4: Incomplete Mocks
+
+**The Problem:** Partial mock responses missing downstream fields that production code expects.
+
+```typescript
+// ❌ BAD: Incomplete mock response
+const mockUserApi = jest.fn().mockResolvedValue({
+  id: 1,
+  name: 'Test User'
+  // Missing: email, createdAt, permissions, settings...
+});
+
+// Test passes, but production crashes when accessing user.email
+```
+
+```typescript
+// ✅ GOOD: Complete mock matching real API response
+const mockUserApi = jest.fn().mockResolvedValue({
+  id: 1,
+  name: 'Test User',
+  email: 'test@example.com',
+  createdAt: '2024-01-01T00:00:00Z',
+  permissions: ['read', 'write'],
+  settings: {
+    theme: 'light',
+    notifications: true
+  }
+});
+
+// Or use a factory
+const mockUserApi = jest.fn().mockResolvedValue(
+  createMockUser({ name: 'Test User' }) // Factory fills defaults
+);
+```
+
+**Solution:** Mirror complete real API response structure. Use factories to generate complete mock objects with sensible defaults.
+
+---
+
+### Anti-Pattern 5: Integration Tests as Afterthought
+
+**The Problem:** Treating testing as optional follow-up work rather than integral to development.
+
+```typescript
+// ❌ BAD: "We'll add tests later"
+// Day 1: Write 500 lines of code
+// Day 2: Write 500 more lines
+// Day 3: "We need to ship, tests can wait"
+// Day 30: Catastrophic bug in production
+// Day 31: "Why didn't we have tests?"
+```
+
+```typescript
+// ✅ GOOD: Tests are part of implementation
+// Write failing test
+it('should reject duplicate usernames', async () => {
+  await createUser({ username: 'alice' });
+
+  await expect(createUser({ username: 'alice' }))
+    .rejects.toThrow('Username already exists');
+});
+
+// Make it pass
+async function createUser(data: UserInput): Promise<User> {
+  const existing = await db.users.findByUsername(data.username);
+  if (existing) {
+    throw new Error('Username already exists');
+  }
+  return db.users.create(data);
+}
+
+// Feature AND test ship together
+```
+
+**Solution:** Follow TDD - testing is implementation, not documentation. No feature is "done" without tests.
+
+---
+
+## Detection Checklist
+
+Review your tests for these warning signs:
+
+| Warning Sign | Anti-Pattern |
+|-------------|--------------|
+| `expect(mock).toHaveBeenCalled()` without testing output | Testing mock behavior |
+| Methods starting with `_` or `ForTesting` in production | Test-only methods |
+| Every dependency is mocked | Mocking without understanding |
+| Mocks return `{ success: true }` only | Incomplete mocks |
+| Test files added weeks after feature ships | Tests as afterthought |
+
+---
+
+## Quick Reference
+
+| Anti-Pattern | Symptom | Fix |
+|-------------|---------|-----|
+| Testing mocks | Only mock assertions, no behavior tests | Assert on actual output |
+| Test-only methods | `_reset()`, `_setForTest()` in prod | Use fresh instances |
+| Over-mocking | 10+ mocks per test | Test with real deps first |
+| Incomplete mocks | Minimal stub responses | Use factories, match reality |
+| Tests as afterthought | Features ship untested | TDD from the start |
+
+---
+
+*Content adapted from [obra/superpowers](https://github.com/obra/superpowers) by Jesse Vincent (@obra), MIT License.*

+ 113 - 0
.claude/skills/test-master/references/unit-testing.md

@@ -0,0 +1,113 @@
+# Unit Testing
+
+## Jest/Vitest Pattern
+
+```typescript
+describe('UserService', () => {
+  let service: UserService;
+  let mockRepo: jest.Mocked<UserRepository>;
+
+  beforeEach(() => {
+    mockRepo = { findById: jest.fn(), save: jest.fn() } as any;
+    service = new UserService(mockRepo);
+  });
+
+  afterEach(() => jest.clearAllMocks());
+
+  describe('getUser', () => {
+    it('returns user when found', async () => {
+      const user = { id: '1', name: 'Test' };
+      mockRepo.findById.mockResolvedValue(user);
+
+      const result = await service.getUser('1');
+
+      expect(result).toEqual(user);
+      expect(mockRepo.findById).toHaveBeenCalledWith('1');
+    });
+
+    it('throws NotFoundError when user not found', async () => {
+      mockRepo.findById.mockResolvedValue(null);
+
+      await expect(service.getUser('1')).rejects.toThrow(NotFoundError);
+    });
+  });
+});
+```
+
+## pytest Pattern
+
+```python
+import pytest
+from unittest.mock import Mock, AsyncMock
+
+class TestUserService:
+    @pytest.fixture
+    def mock_repo(self):
+        return Mock()
+
+    @pytest.fixture
+    def service(self, mock_repo):
+        return UserService(mock_repo)
+
+    async def test_get_user_returns_user(self, service, mock_repo):
+        mock_repo.find_by_id = AsyncMock(return_value={"id": "1", "name": "Test"})
+
+        result = await service.get_user("1")
+
+        assert result == {"id": "1", "name": "Test"}
+        mock_repo.find_by_id.assert_called_once_with("1")
+
+    async def test_get_user_raises_not_found(self, service, mock_repo):
+        mock_repo.find_by_id = AsyncMock(return_value=None)
+
+        with pytest.raises(NotFoundError):
+            await service.get_user("1")
+```
+
+## Mocking Patterns
+
+```typescript
+// Mock functions
+const mockFn = jest.fn();
+mockFn.mockReturnValue('value');
+mockFn.mockResolvedValue('async value');
+mockFn.mockRejectedValue(new Error('error'));
+
+// Mock modules
+jest.mock('./database', () => ({
+  query: jest.fn(),
+}));
+
+// Spy on existing methods
+jest.spyOn(console, 'log').mockImplementation(() => {});
+```
+
+## Test Organization
+
+```typescript
+describe('Feature', () => {
+  describe('happy path', () => {
+    it('does expected behavior', () => {});
+  });
+
+  describe('edge cases', () => {
+    it('handles empty input', () => {});
+    it('handles max values', () => {});
+  });
+
+  describe('error cases', () => {
+    it('throws on invalid input', () => {});
+  });
+});
+```
+
+## Quick Reference
+
+| Pattern | Use Case |
+|---------|----------|
+| `describe()` | Group related tests |
+| `it()` / `test()` | Single test case |
+| `beforeEach()` | Setup before each test |
+| `jest.fn()` | Create mock function |
+| `mockResolvedValue()` | Mock async return |
+| `expect().toThrow()` | Assert exception |

+ 7 - 0
.gitignore

@@ -50,3 +50,10 @@ composeApp/src/commonMain/resources/keys/*.key
 /composeApp/src/commonMain/resources/keys/*.key
 .intellijPlatform/
 .intellijPlatform
+
+### ai
+!.claude
+!.claude/
+!/.claude
+!/.claude/
+!.claude/*