# Technical Documentation: kurikomi-labs/komi-store

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/kurikomi-labs/komi-store)

### Overview (README.md)

<div align="center">
  <br/>
  <img src="media-resources/app_icon.png" width="200" alt="Komi Store app icon" />

# Komi Store

</div>

<p align="center">
 <a href="https://github.com/komi-store/komi-store/releases/latest">
  <img src="https://api.github-store.org/v1/badge/komi-store/komi-store/downloads/5/2?label=Downloads%20:" alt="Downloads"/>
</a>
<a href="https://github.com/komi-store/komi-store/stargazers">
  <img src="https://m3-markdown-badges.vercel.app/stars/3/2/komi-store/komi-store" alt="Stars"/>
</a>
<a href="https://github.com/komi-store/komi-store/releases/latest">
  <img src="https://api.github-store.org/v1/badge/komi-store/komi-store/release/9/1?label=Latest%20version%20:" alt="Latest release"/>
</a>
</p>

<table align="center">
  <tr>
    <td>
      <a href="https://trendshift.io/repositories/22313" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22313" alt="komi-store%2Fkomi-store | Trendshift" width="250" height="55" /></a>
    </td>
    <td>
      <a href="https://hellogithub.com/en/repository/kurikomi-labs/komi-store" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=a95f4a4830bc4a69b56f96ac7efaacf8&claim_uid=sOz1lfiG4ARQYIK&theme=dark" alt="Featured｜HelloGitHub" width="250" height="54" /></a>
    </td>
  </tr>
</table>

<div align="center">

## Project Overview 🗺️

Komi Store is a cross-platform app store for releases on GitHub, Codeberg and other Forgejo or Gitea forges, designed to simplify discovering and installing open-source software.

</div>

---

<div align="center">
  <a href="https://github.com/Safouene1/support-palestine-banner/blob/master/Markdown-pages/Support.md">
    <img src="https://raw.githubusercontent.com/Safouene1/support-palestine-banner/master/banner-project.svg" alt="Support Palestine" style="width: 100%;" />
  </a>
</div>

> [!CAUTION]
> Free and Open-Source Android is under threat. Google will turn Android into a locked-down platform, restricting your essential freedom to install apps of your choice. Make your voice heard – [keepandroidopen.org](https://keepandroidopen.org/).

<p align="center">
  <img src="media-resources/banner.jpeg" width="99%" />
</p>

| 1 | 2 | 3 | 4 | 5 | 6 |
| --- | --- | --- | --- | --- | --- |
|  |  |  |  |  |  |

---

### Download

<p>
  <a href="https://github.com/komi-store/komi-store/releases">
    <img src="https://i.ibb.co/q0mdc4Z/get-it-on-github.png" height="80" alt="Get it on GitHub" />
  </a>
  <a href="https://f-droid.org/en/packages/zed.rainxch.githubstore/">
    <img src="https://f-droid.org/badge/get-it-on.png" height="80" alt="Get it on F-Droid" />
  </a>
</p>

> [!IMPORTANT]
> **macOS Users:** You may see a warning that Apple cannot verify Komi Store. This happens because the app is distributed outside the App Store and is not notarized yet. Allow it via System Settings → Privacy & Security → Open Anyway, or run `xattr -dr com.apple.quarantine /Applications/Komi-Store.app` after installing.

**Scoop**

```powershell
scoop bucket add komi-store https://github.com/komi-store/komi-store-scoop-bucket
scoop install komi-store/komi-store
```

**WinGet**

```powershell
winget install zed.rainxch.githubstore
```

> [!TIP]
> **macOS Users:** Install Komi Store with Homebrew.

**Homebrew**

```bash
brew tap komi-store/komi-store
brew install --cask komi-store
```
---

### Features

- **Smart discovery**
- **Recently Released**
- **Release browser & installs**
- **Download mirror system**
- **Rich details screen**
- **App management**
- **Collections**
- **Tweaks**
- **Localization** (Available in 14 languages)
- **Network & performance**

---

### Team

<table align="center">
  <tr>
    <td align="center">
      <a href="https://github.com/rainxchzed">
        <picture>
          <source media="(prefers-color-scheme: dark)" srcset="https://wsrv.nl/?url=github.com%2Frainxchzed.png%3Fsize%3D400&precrop=true&cx=32&cy=32&cw=336&ch=336&w=220&h=220&fit=cover&mask=circle&mbg=0d1117&maxage=7d" />
          <img src="https://wsrv.nl/?url=github.com%2Frainxchzed.png%3Fsize%3D400&precrop=true&cx=32&cy=32&cw=336&ch=336&w=220&h=220&fit=cover&mask=circle&mbg=ffffff&maxage=7d" width="110" alt="rainxchzed" />
        </picture>
      </a>
      <br />
      <a href="https://github.com/rainxchzed"><b>rainxchzed</b></a>
      <br />
      <img src="https://img.shields.io/badge/Creator-3D4654?style=flat-square" alt="Creator" />
    </td>
    <td align="center">
      <a href="https://github.com/illumiat">
        <picture>
          <source media="(prefers-color-scheme: dark)" srcset="https://wsrv.nl/?url=github.com%2Fillumiat.png%3Fsize%3D400&precrop=true&cx=32&cy=32&cw=336&ch=336&w=220&h=220&fit=cover&mask=circle&mbg=0d1117&maxage=7d" />
          <img src="https://wsrv.nl/?url=github.com%2Fillumiat.png%3Fsize%3D400&precrop=true&cx=32&cy=32&cw=336&ch=336&w=220&h=220&fit=cover&mask=circle&mbg=ffffff&maxage=7d" width="110" alt="illumiat" />
        </picture>
      </a>
      <br />
      <a href="https://github.com/illumiat"><b>illumiat</b></a>
      <br />
      <img src="https://img.shields.io/badge/Maintainer-3D4654?style=flat-square" alt="Maintainer" />
    </td>
    <td align="center">
      <a href="https://github.com/KelvinCrag">
        <picture>
          <source media="(prefers-color-scheme: dark)" srcset="https://wsrv.nl/?url=github.com%2FKelvinCrag.png%3Fsize%3D400&precrop=true&cx=32&cy=32&cw=336&ch=336&w=220&h=220&fit=cover&mask=circle&mbg=0d1117&maxage=7d" />
          <img src="https://wsrv.nl/?url=github.com%2FKelvinCrag.png%3Fsize%3D400&precrop=true&cx=32&cy=32&cw=336&ch=336&w=220&h=220&fit=cover&mask=circle&mbg=ffffff&maxage=7d" width="110" alt="KelvinCrag" />
        </picture>
      </a>
      <br />
      <a href="https://github.com/KelvinCrag"><b>KelvinCrag</b></a>
      <br />
      <img src="https://img.shields.io/badge/Maintainer-3D4654?style=flat-square" alt="Maintainer" />
    </td>
    <td align="center">
      <a href="https://github.com/YumeYuka">
        <picture>
          <source media="(prefers-color-scheme: dark)" srcset="https://wsrv.nl/?url=github.com%2FYumeYuka.png%3Fsize%3D400&precrop=true&cx=32&cy=32&cw=336&ch=336&w=220&h=220&fit=cover&mask=circle&mbg=0d1117&maxage=7d" />
          <img src="https://wsrv.nl/?url=github.com%2FYumeYuka.png%3Fsize%3D400&precrop=true&cx=32&cy=32&cw=336&ch=336&w=220&h=220&fit=cover&mask=circle&mbg=ffffff&maxage=7d" width="110" alt="YumeYuka" />
        </picture>
      </a>
      <br />
      <a href="https://github.com/YumeYuka"><b>YumeYuka</b></a>
      <br />
      <img src="https://img.shields.io/badge/Maintainer-3D4654?style=flat-square" alt="Maintainer" />
    </td>
  </tr>
</table>

<p align="center">
  <sub>Maintainers are listed in alphabetical order.</sub>
</p>

---

### Join Community

<p align="center">
  <a href="https://discord.komistore.app"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
  <a href="https://t.me/komistoreapp"><img src="https://img.shields.io/badge/Telegram%20Channel-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel" /></a>
  <a href="https://t.me/komistore_discussion"><img src="https://img.shields.io/badge/Telegram%20Group-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Group" /></a>
</p>

---


### APK Signing Certificate

SHA-256:
`B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA`

---

### Business Inquiries

Komi Store is open to partnerships, sponsorships, and integrations.

If you're interested in working together, reach out:

📧 **Email:** hello@komistore.app
💬 **Discord:** [Join our community](https://discord.komistore.app)

---

### Disclaimer

Komi Store only helps you discover and download release assets that are already published on
GitHub, Codeberg and other forges by third‑party developers.
The contents, safety, and behavior of those downloads are entirely the responsibility of their
respective authors and distributors, not this project.

By using Komi Store, you understand and agree that you install and run any downloaded software at
your own risk.
This project does not review, validate, or guarantee that any installer is safe, free of malware, or
fit for any particular purpose.

---

### Code Style (`docs/code-style.md`)

# Komi Store code style

Status: **adopted** on 2026-10-19 from the decisions in [findings.md](quality/findings.md). Some rules get their automated check in follow-up work; until then they apply to new and touched code. Links point at examples in [`cfa6bd30`](https://github.com/komi-store/komi-store/tree/cfa6bd30e6b35cbbc369d78a4599182a9a66beac).

This file is for people and agents writing code here. CLAUDE.md files point to it. A tag after a rule says what enforces it:

- **[Spotless]**: `./gradlew spotlessCheck` in CI: ktlint 1.8.0 with the Compose rules, on files changed against `main`
- **[detekt]**: `./gradlew detekt` in CI, with per-module baselines
- **[Konsist]**: `./gradlew :konsist-test:test` in CI
- **[review]**: maintainer review, and agents' own review before opening a PR; the mechanical ones move to Konsist in follow-up work

When a rule and nearby code disagree, follow the rule. Fix the nearby code only if your change already touches it.

## 1. Formatting and files

- ktlint with the Compose rules decides formatting. Run `./gradlew spotlessApply` before pushing; nothing formats on compile. **[Spotless]**
- No wildcard imports, including `res.*`. ktlint's `no-wildcard-imports` is still disabled in `.editorconfig`. **[review]**
- One top-level public type per file, named after it. **[review]**
- Production files stay under 800 lines. Today's larger files are on an allowlist that can only shrink. **[Konsist]**

## 2. Comments

- No KDoc, no comments that restate the code. **[review]**
- Comment only a non-obvious invariant, tricky concurrency or a workaround, in one or two lines. Good: [AppsRepositoryImpl.kt:300-301](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt#L300-L301), [BackgroundRequest.kt:13-14](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackgroundRequest.kt#L13-L14).
- No em-dashes anywhere: code, strings, logs, comments. **[review]**

## 3. Modules and packages

- Packages are `zed.rainxch.<feature>.<layer>`, e.g. `zed.rainxch.devprofile.data` (hyphens dropped). **[Konsist]**
- Common domain code imports no Ktor, Room, Compose, lifecycle, Koin or Android types. Platform `actual`s may. **[Konsist]**
- Presentation never imports a data package. **[Konsist]**
- Repository interfaces live in domain, `XRepositoryImpl` in data. **[review]**
- No `java.*` or `android.*` in `commonMain`. Bad: [AppsViewModel.kt:74](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L74). **[review]**

## 4. ViewModels

The recommended shape, already used by 11 ViewModels ([AppsViewModel.kt:113-128](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L113-L128), [StarredReposViewModel.kt:39-55](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposViewModel.kt#L39-L55)):

```kotlin
class FooViewModel(
    private val repository: FooRepository,
) : ViewModel() {
    private var hasLoadedInitialData = false

    private val _state = MutableStateFlow(FooState())
    val state =
        _state
            .onStart {
                if (!hasLoadedInitialData) {
                    load()
                    hasLoadedInitialData = true
                }
            }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), FooState())

    private val _events = Channel<FooEvent>(Channel.BUFFERED)
    val events = _events.receiveAsFlow()

    fun onAction(action: FooAction) {
        when (action) {
            FooAction.OnRefresh -> load()
        }
    }
}
```

- `FooAction` and `FooEvent` are sealed. **[Konsist]**
- One public input: `onAction`. No other public functions. **[review]**
- Update state with `_state.update { }`, never `_state.value = _state.value.copy(...)`. **[review]**
- Combine flows with `combine`, not several collectors writing shadow `var`s. Bad: [HomeViewModel.kt:64-86](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt#L64-L86). **[review]**
- Route arguments come from the route object or one typed params class, never positional `parametersOf` (*A8*). Bad: [ViewModelsModule.kt:47-52](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt#L47-L52). **[review]**

## 5. State

- A `data class FooState` with defaults, `ImmutableList` / `ImmutableSet` for collections. **[review]**
- No `@Stable` / `@Immutable` on screen state; keep `@Stable` for real state holders like [KomiToastState.kt:8](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/KomiToastState.kt#L8). **[review]**
- Derive values in one place before the UI sees them. Good: [RawDetailsStateMapper.kt:11](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/RawDetailsStateMapper.kt#L11). **[review]**
- No sentinel strings or nullable fields used as flags. Bad: [AppsViewModel.kt:880](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L880). **[review]**
- Errors in state are typed, not `String`. **[review]**

## 6. Errors and cancellation

- Never wrap suspend calls in `runCatching`; it swallows `CancellationException`. **[review]**
- A broad `catch` around suspend code rethrows `CancellationException` first. Good: [DetailsViewModel.kt:208-212](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt#L208-L212). **[review]**
- `Throwable.message` never reaches the user. Map typed errors to resources at render time. Good: the typed cases in [MirrorPickerRoot.kt:240-245](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerRoot.kt#L240-L245) (its fallback case on the next line still shows exception text). Bad: [DetailsViewModel.kt:3009](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt#L3009). **[review]**
- Fallible repository calls return `Result<T>` with typed domain exceptions. A typed outcome to copy: [ProxyTestOutcome.kt:3-27](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/ProxyTestOutcome.kt#L3-L27).
- Log with the injected `KomiStoreLogger` and pass the throwable, never only `.message`. **[review]**

## 7. Navigation

- Routes are `@Serializable` members of `GithubStoreGraph` ([GithubStoreGraph.kt:5](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt#L5)). **[review]**
- A click that only navigates is a lambda on the Screen (`onNavigateToDetails`), not an Action. **[review]**
- The ViewModel sends a `Navigate*` event only when navigation depends on async work. **[review]**
- No empty `-> Unit` branches for navigation in `onAction`. Bad: [HomeViewModel.kt:212](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt#L212). **[review]**

## 8. Composables

- A public `FooRoot(callbacks, viewModel = koinViewModel())` collects with `collectAsStateWithLifecycle`, handles events with `ObserveAsEvents`, and calls a private stateless `FooScreen(state, onAction, modifier)`. Closest today: [ImportStarsRoot.kt:64-88](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/import/ImportStarsRoot.kt#L64-L88), which still lacks the `modifier` parameter, as every route-level screen does. **[review]**
- Every composable that emits UI takes `modifier: Modifier = Modifier` as its first optional parameter. **[Spotless]**
- Pass leaf components the values they need, not the whole screen state. Bad: [SmartInstallButton.kt:91-99](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt#L91-L99). **[review]**
- No business logic, `koinInject`, or dispatcher switching inside composables. Bad: [About.kt:98-100](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt#L98-L100), [About.kt:140-149](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt#L140-L149). **[review]**
- Lazy list items have stable keys. **[review]**
- Anything named `rememberX` calls `remember`. Bad: [MarkdownUtils.kt:17](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/markdown/MarkdownUtils.kt#L17). **[review]**

## 9. Design system

- Use `Komi*` components and `LocalPersonality.current` tokens. No `MaterialTheme.*`, no `Color(0x...)` literals, no `isSystemInDarkTheme()`. Bad: [DetailsRoot.kt:305](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt#L305), [ExpandableDetails.kt:89](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/markdown/ExpandableDetails.kt#L89). **[review]**
- Before writing a component, look in core/presentation. Shared needs (empty and error states, favourite toggle, search bar, sort dropdown) belong there, not in a feature. **[review]**
- New core components dispatch on the personality with private Manga / Classic implementations, like [KomiButton.kt:70-123](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/KomiButton.kt#L70-L123). **[review]**
- Public core components and stateless screens have a preview wrapped in `PersonalityPreview` ([PersonalityPreview.kt:16](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/personality/utils/PersonalityPreview.kt#L16)). **[review]**

- Text goes through `KomiText(role = ...)`. The role sets size, weight, letter spacing and case; `KomiText` has no parameters to override them. Seven sizes: 11, 12, 14, 16, 20, 24, 32. **[Konsist]**

  | Role | Size | Use it for |
  |---|---|---|
  | `Display` | 32 | Hero names, onboarding headlines |
  | `ScreenTitle` | 24 | Top bar titles |
  | `SectionTitle` | 20 | Page sections, empty-state titles |
  | `CardTitle` | 16 | Card, sheet and dialog titles |
  | `ItemTitle` | 16 | List rows, picker options |
  | `Body` | 14 | Running text, row subtitles |
  | `Reading` | 16 | Long-form text: release notes, descriptions |
  | `Caption` | 12 | Meta: counts, dates, sizes |
  | `Mono` | 12 | Versions, hashes, package names, logs |
  | `LabelLarge`, `Label`, `LabelSmall` | 16, 14, 12 | Controls by size: large, medium and small buttons, chips, navigation |
  | `Overline` | 11 | Kickers above a field or group, always in capitals |
  | `Stamp` | 12 to 14 | Segmented tabs, badges, Manga stamps |
  | `Numeral` | 24 | Stat values |

- Case comes from the role. Manga sets `Display`, `ScreenTitle`, `SectionTitle` and `Stamp` in capitals; everything else keeps the text's own case. A selected row shows its state with color or a check, not a heavier weight. **[review]**
- Large text that must keep its own case or face (the repository name in Details, the device sign-in code) uses `BasicText` with a personality style resized from another role, such as `type.display` or `type.mono.copy(fontSize = type.screenTitle.fontSize)`. Never a literal size. **[Konsist]**
- Tints come from `PersonalityColors`: `primaryTint`, `onPrimaryTint`, `errorTint`, `pressOverlay`, `disabledContent`, `disabledContainer`, `scrimOverlay` and `divider`. No `.copy(alpha = 0.x)` on personality colors. **[Konsist]**
- Motion comes from `LocalPersonality.current.motion`: `fastSpatial()` and `defaultSpatial()` for movement, `bouncySpatial()` for a press or swap that should feel alive, `fastEffects()`, `defaultEffects()` and `slowEffects()` for fades and color, `short()`, `medium()` and `long()` durations, `ambient()` for loops. Stop a loop when `motion.animates` is false. **[Konsist]**
- A screen with a back action passes `onBack` to `KomiTopBar`, not an arrow in `leading`. In Classic the top bar hands `onBack`, `actions` and the scaffold's FAB to a `KomiActionBar` floating over the content at the bottom (a cookie back button, the actions in a floating toolbar) and keeps only the title; both slide away while the content scrolls down. Manga draws them in the top bar. Pass `actions = null` when a screen has none to show, not an empty lambda. **[review]**
- Classic tab screens have no top bar: their `KomiScaffold` draws a tab card at the bottom (a search field, the screen's actions and FAB, and the tab row, which folds away while the content scrolls down). The app shell gives each tab destination a `LocalTabCardSlot` and the tabs; the screen only uses `KomiTopBar` as usual. A tab with its own search field (Library's filter) passes it as `search`; on the others the card's field opens Search, which is not a tab. Its list scrolls under the card: pad the container with `innerPadding.withoutBottom()` and add `innerPadding.calculateBottomPadding()` to the list's content padding. **[review]**
- Popups go through core components: `KomiTooltip`, `KomiRichTooltip` and `KomiCoachmark` for tips, `KomiDropdown` for menus, `KomiMenuList` for a sheet that lists options (a picker), `KomiSheet` for sheets. **[Konsist]**

## 10. Strings and accessibility

- All user-visible text comes from `Res.string`, content descriptions included. Bad: [KomiChip.kt:255](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/KomiChip.kt#L255). **[review]**
- Counts use `<plurals>`, not "(s)". **[review]**
- Toggles and selectable rows set `Role` and `stateDescription`; error states announce themselves with `liveRegion`. **[review]**
- New keys go into the default `strings.xml` with a feature prefix (`tweaks_...`). **[review]**

## 11. Data layer and coroutines

- DTOs live in `dto/`; mappers live in `mappers/` as `toDomain()` extensions. **[review]**
- Use the injected HTTP clients. No `HttpClient {}` in feature code: those skip the proxy and are never closed. Bad: [FeedRepositoryImpl.kt:41](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/feed/data/src/commonMain/kotlin/zed/rainxch/feed/data/repository/FeedRepositoryImpl.kt#L41). **[review]**
- Long-lived work runs on the injected app scope, not a private `CoroutineScope` in a singleton. **[review]**
- No `runBlocking` outside app startup. **[review]**

## 12. DI

- Data modules declare `single<FooRepository> { FooRepositoryImpl(named = get()) }`. **[review]**
- ViewModels use `viewModelOf(::FooViewModel)` unless they take route arguments. **[review]**
- No stateful `object` singletons outside DI, and no `named("test")` for production clients. Bad: [ProxyManager.kt:23](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt#L23). **[review]**

## 13. Tests

- `kotlin.test`, hand-written fakes, no mocking library. **[review]**
- Test names are snake_case sentences, like [UpdateVerdictTest.kt:62](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonTest/kotlin/zed/rainxch/core/domain/utils/UpdateVerdictTest.kt#L62). **[review]**
- Shared fakes live in one place per module instead of one copy per test file. **[review]**
- New ViewModels come with tests once `kotlinx-coroutines-test` and Turbine are in the version catalog (follow-up).

## 14. Docs

- CLAUDE.md files state only what the code can't tell you in a minute. **[review]**
- Change a CLAUDE.md in the same PR as the code it describes. **[review]**

---

### Agents Architecture (`docs/agents/architecture.md`)

# Architecture

Clean Architecture with MVVM. **Domain** holds contracts, models and pure logic. **Data** implements them with Ktor, Room and Koin. **Presentation** holds ViewModels (`StateFlow` state, `Channel` events) and Compose UI. The ViewModel and screen shapes are in `docs/code-style.md`, sections 4 and 8.

## Modules

```text
androidApp/            Android application: MainActivity, GithubStoreApp, manifest, resources, R8 rules
composeApp/            shared app code, navigation, DI wiring, Desktop entry point (commonMain / jvmMain)
core/domain            interfaces, models, use cases (no framework deps)
core/data              repositories, Ktor, Room, Koin, platform implementations
core/data-aidl         the Shizuku and Dhizuku installer AIDL interfaces (Android only)
core/presentation      Personality design system (Classic / Manga), Komi* components, 14-locale strings
feature/               apps auth details dev-profile favourites feed home profile recently-viewed repo-pages search starred tweaks
konsist-test/          architecture rules
build-logic/convention convention plugins
```

Each feature has up to three sub-modules: `domain/`, `data/`, `presentation/`. `profile`, `recently-viewed` and `tweaks` are presentation-only. `favourites` and `starred` also have `domain/` and `data/` modules, currently empty.

## Navigation

Routes are members of the `@Serializable` sealed interface `GithubStoreGraph` in `composeApp/.../app/navigation/GithubStoreGraph.kt`; `AppNavigation.kt` wires them. Two route arguments matter beyond their names:

- `DetailsScreen.packageName` (set from Apps) opens Details on that installed app when a repository ships several apps.
- `DetailsScreen.sourceHost` is non-null for Codeberg, Forgejo and custom-forge repositories and routes every `DetailsRepository` call through `ForgejoClientRegistry` instead of the GitHub-backed path.

`RepoPullsScreen` is registered but nothing navigates to it yet.

## DI

Koin. Each feature's data module declares its bindings in `data/di/SharedModule.kt`. ViewModels are registered in `composeApp/.../app/di/ViewModelsModule.kt` (`viewModelOf(::X)`, or `viewModel { ... }` when they take route arguments). Modules are assembled in `initKoin.kt`.

## Core repositories (`core/domain`)

`FavouritesRepository`, `StarredRepository`, `InstalledAppsRepository`, `SeenReposRepository`, `HiddenReposRepository`, `TweaksRepository`, `UserSessionRepository` (signed-in user, token, session expiry), `ProxyRepository`, `RateLimitRepository`, `ExternalImportRepository`, `MirrorRepository`, `AnnouncementsRepository`, `CacheRepository`, `BrowseFilterStore`, `HostTokenRepository`. `SearchHistoryRepository` lives in `feature/search/domain`.

System interfaces with Android and Desktop implementations: `Installer`, `InstallerStatusProvider`, `PackageMonitor`, `SystemInstallSerializer`.

## Convention plugins (`build-logic/convention/`)

`convention.kmp.library` (domain and data), `convention.cmp.library` (core/presentation and composeApp), `convention.cmp.feature` (feature presentation), `convention.android.application.compose` (androidApp), `convention.android.library` (core/data-aidl), `convention.room`, `convention.buildkonfig`, `convention.quality` (detekt and Kover, applied by the library and app conventions). The Compose conventions also apply the stability analyzer; KMP modules use the Android KMP library plugin (`com.android.kotlin.multiplatform.library`), configured in `configureAndroidTarget`; it creates lint tasks only because `com.android.lint` is applied as well, which also adds a `lintJvm` that CI doesn't run. Lint options are shared in `configureLint`.

## Adding a feature

1. Create `feature/<name>/{domain,data,presentation}/` with the matching convention plugin.
2. `include` the modules in `settings.gradle.kts`.
3. Domain interfaces, then the implementation and its Koin module in `data/di/SharedModule.kt`, then the ViewModel and screen.
4. Add the route to `GithubStoreGraph.kt`, wire it in `AppNavigation.kt`, register the ViewModel in `ViewModelsModule.kt` and the data module in `initKoin.kt`.
5. Add a `feature/<name>/CLAUDE.md` with what a reader can't see from the code in a minute.

---

### Agents Checks (`docs/agents/checks.md`)

# Checks

CI (`.github/workflows/pr-checks.yml`) runs on every pull request and every push to `main`. Existing findings live in committed baselines, so a check fails only on new issues.

```bash
./gradlew spotlessApply                                   # format files changed vs origin/main (ktlint 1.8.0 + Compose rules + .editorconfig)
./gradlew detekt                                          # static analysis; baseline: <module>/detekt-baseline.xml
./gradlew lintDebug lintAndroidMain                       # Android Lint; baseline: <module>/lint-baseline.xml
./gradlew jvmTest koverXmlReport                          # tests and merged coverage (build/reports/kover)
./gradlew compileKotlinJvm compileAndroidMain; ./gradlew stabilityCheck   # Compose stability vs <module>/stability/<project>.stability
./gradlew :konsist-test:test                              # architecture rules
./gradlew spotlessInstallGitPrePushHook                   # opt-in pre-push format check
```

CodeQL (`codeql.yml`) and Dependabot (`dependabot.yml`) run separately.

## Spotless

Spotless checks only files changed against `origin/main`, but it checks the whole file. The codebase was formatted once in #1080 (listed in `.git-blame-ignore-revs`). Compose rule findings (missing `modifier`, parameter order, naming) are not auto-fixable and have to be fixed by hand in files you touch. A new `CompositionLocal` goes into `compose_allowed_composition_locals` in `.editorconfig`.

After editing `.editorconfig`, Spotless can keep reporting results from before the edit, because Gradle's build cache restores the `spotlessKotlin` lint output. To see the real result:

```bash
./gradlew --stop
rm -rf build/spotless-clean build/spotless-lints .gradle/configuration-cache
./gradlew spotlessApply --no-build-cache
```

## Baselines

ktlint findings the formatter can't fix (mostly Compose rules) are listed per file and rule in `config/ktlint/baseline.txt`, and Spotless suppresses exactly those. When you fix one, delete its line.

Refresh a baseline only for an intentional change: `detektBaseline`, `updateLintBaseline` (`updateLintBaselineAndroidMain` in KMP modules) or `stabilityDump`. The stability analyzer (0.15.0) merges what every compilation that ran wrote under `build/stability/`, so compile the JVM and Android main targets first and run its tasks in their own Gradle invocation. Kotlin must match the stability analyzer's Kotlin version (see its version map) when bumping either.

## Warnings as errors

Kotlin warnings are errors (`allWarningsAsErrors`). A module can opt out with `komi.warningsAsErrors=false` in its own `gradle.properties`; none does.

## Konsist

The rules and their allowlists live in `konsist-test/src/test/kotlin/zed/rainxch/konsist/`. An allowlist can only shrink: the file-size test fails when an allowlisted file drops under the limit, so remove the entry in the same PR.

`DocsRulesTest` reads `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, `docs/agents/*.md` and `feature/*/CLAUDE.md`. A backticked route such as `GithubStoreGraph.DetailsScreen` must exist in the graph, and any backticked name ending in Screen or Graph must still exist in production code. When you rename a route or screen, update the docs in the same PR.

`DesignTokenRulesTest` keeps design values in the personality package (`core/presentation/.../personality/`). Outside it, it bans raw `.sp` sizes, `FontWeight.`, hand-built `TextStyle(`, literal `tween` durations, literal `.copy(alpha = ...)` tints and `Color(0x...)`. Outside `core/presentation` it also bans Material's `Text`, `MaterialTheme.typography`, `Popup` and `TooltipBox`. Today's matches are counted per file in `konsist-test/src/test/resources/design-tokens/`: a file may not go over its count, and when it drops, lower or delete its line in the same PR.

## Agent config

The `Agent config` job runs [agnix](https://github.com/agent-sh/agnix) over the repository (`AGENTS.md`, the `CLAUDE.md` files, `.claude/` and the rest) with its default rules, and fails only on errors. It runs in CI only.

---

### Agents Distribution (`docs/agents/distribution.md`)

# Distribution (maintainers)

Desktop installers are built by `.github/workflows/build-desktop-platforms.yml` on every push to the `generate-installers` branch. Its `release` job reads `projectVersionName` and creates the draft release `v<version>`.

## Windows signing (SignPath Foundation)

The `sign-windows` job runs after every push to `generate-installers`. The SignPath action is pinned to a commit SHA. Secrets: `SIGNPATH_API_TOKEN`, `SIGNPATH_ORGANIZATION_ID` (`1ecf111e-...`). The variable `SIGNPATH_SIGNING_POLICY_SLUG` is `test-signing` until the production certificate is issued, then `release-signing`. Project slug `GitHub-Store`, artifact configuration slug `initial`. The unsigned artifact is deleted after signing, so only `windows-installers-signed` reaches the draft release.

## WinGet

`.github/workflows/winget-publish.yml` fires on `release: [released]` and uses `vedantmgoyal9/winget-releaser` to publish the MSI (`installers-regex: '\.msi$'`) as `zed.rainxch.githubstore`. The secret `WINGET_TOKEN` is a PAT with `Contents` and `Pull requests` write access on `komi-store/winget-pkgs` (a fork of `microsoft/winget-pkgs`). The workflow doesn't set `fork-user`; the action defaults it to the repository owner, `komi-store`.

## Scoop

`.github/workflows/scoop-publish.yml` fires on release (or by hand) and dispatches `komi-store-release` to `komi-store/komi-store-scoop-bucket` with the secret `SCOOP_BUCKET_TOKEN`.

## macOS (Homebrew)

The cask lives in the tap `komi-store/komi-store` (repository `homebrew-komi-store`): `brew install --cask komi-store`. The app is unsigned for now, so users run `xattr -dr com.apple.quarantine /Applications/Komi-Store.app` after installing. CI builds `.dmg` and `.pkg` on every push to `generate-installers`. On release, `.github/workflows/homebrew-tap-publish.yml` updates `Casks/komi-store.rb` in the tap with the secret `HOMEBREW_TAP_TOKEN`; it can also be run by hand for a given tag.

---

### Agents Forges (`docs/agents/forges.md`)

# Codeberg, Forgejo and Gitea

Forge repositories are fetched directly from the forge, with no backend in between.

- **Clients:** one `ForgejoApiClient` per host (60 s request, 30 s connect and socket timeouts, exponential retry on 5xx and `IOException`). `ForgejoClientRegistry.clientFor(host)` caches them behind a Mutex; it is proxy-aware and drops cached clients when the proxy changes.
- **IDs:** `RepoIdCodec` packs a 23-bit host fingerprint and the 40-bit raw id into the existing 64-bit `repoId` slot (sign bit set = foreign source), so the GitHub-shaped schema stays as it is.
- **URLs:** `RepositoryUrlParser` recognises GitHub, Codeberg, gitea.com, git.disroot.org and user-added forge hosts.
- **README:** `/contents/README.md?ref={branch}`; Forgejo has no `/readme` endpoint.
- **License:** the first of `LICENSE`, `LICENSE.md`, `LICENSE.txt` and `COPYING` found under `/contents/`, matched against SPDX headers.
- **Downloads:** the sum of `asset.download_count` across releases.
- **Details:** a non-null `DetailsScreen.sourceHost` routes every `DetailsRepository` call through `ForgejoClientRegistry`.

## Per-host access tokens

`HostTokenRepository` stores `HostToken(host, token, displayName, createdAtEpochMillis)` entries as one JSON list under a single KSafe key (AES-256-GCM). Tweaks, Access Tokens (`HostTokensScreen`) adds, validates and deletes them. `HostTokenInterceptor` (a Ktor plugin on every `ForgejoApiClient` and the GitHub-direct client; never the backend, `test` or download clients) adds `Authorization: token $pat` to an https request whose host, with its port, matches a saved token, reading it per request so edits apply without a restart. An `Authorization` already set, such as the GitHub account sign-in, wins. `HostNames.apiHostToTokenHost` maps `api.github.com`, `uploads.github.com` and `raw.githubusercontent.com` to the `github.com` token. Ktor drops the header on cross-host redirects, and the mirror rewrite drops it when the host changes.

---

### Agents Platform (`docs/agents/platform.md`)

# Platform notes

## Installing apps on Android

Silent installs go through Shizuku (`ShizukuProvider`, then AIDL, then `pm install -S`), Dhizuku, or root through libsu (`services/root/RootServiceManager`). `services/installer/SilentInstallerDispatcher` in `core/data/src/androidMain` picks one from the installer setting and falls back to the standard installer on failure.

The standard installer (setting Default) opens the system package installer directly when other APK installers are present and Android has no default for them, so no chooser appears. A handed-off install keeps `SystemInstallSerializer`'s gate until the package broadcast arrives; `MainActivity.onResume` releases it, because a cancelled installer screen sends no broadcast.

The Shizuku and Dhizuku AIDL interfaces live in `core/data-aidl`, a plain Android library, because the Android KMP library plugin (`com.android.kotlin.multiplatform.library`) has no AIDL support. They keep their `zed.rainxch.core.data.services.*` packages, which the Shizuku keep rules in `androidApp/proguard-rules.pro` name.

## Local network access

The app targets Android 17, which blocks traffic to LAN addresses (Wi-Fi or Ethernet; private, link-local, CGNAT and multicast ranges) without the `ACCESS_LOCAL_NETWORK` runtime permission. `MainActivity` resolves the hosts of the HTTP/SOCKS proxies, custom forges and the LibreTranslate URL (`LocalNetworkHosts.kt`). When any of them is on the LAN, it asks for the permission at launch, and asks again in the same session only when that set of hosts changes. After two denials Android stops showing the prompt. The address ranges are in `core/domain/.../network/LocalNetworkAddress.kt`. Loopback isn't a local network, so an on-device proxy at 127.0.0.1 needs nothing.

## Desktop logs

`CrashReporter.install()`, called at the start of `DesktopApp.main`, tees stdout and stderr to a rotating `session.log` and writes `crash-<ts>.log` on an uncaught exception.

| OS | Path |
|---|---|
| macOS | `~/Library/Logs/Komi-Store/` |
| Windows | `%LOCALAPPDATA%/Komi-Store/logs/` |
| Linux | `$XDG_STATE_HOME/Komi-Store/logs/` (or `~/.local/state/Komi-Store/logs/`) |

Android logs go to Logcat.

## Encrypted storage

KSafe provides AES-256-GCM, backed by the hardware Keystore on Android. Persisted credentials and preferences go through the extension functions in `core/data/secure/KSafeSafe.kt` (`safeGet`, `safePut`, `safeDelete`, `safeGetFlow`), which log and return null or false on a transient failure instead of throwing through coroutine scopes. A few raw calls remain, for example in `HostTokenRepositoryImpl`.

## Gradle

Configuration cache and build cache are on. The Gradle daemon gets 4 GB of heap and the Kotlin daemon 3 GB (`gradle.properties`). Library versions live in `gradle/libs.versions.toml`.

---

### Agents Releases And Assets (`docs/agents/releases-and-assets.md`)

# Releases, assets and installed apps

How a release asset is matched to the platform and to an installed app. All of this is pure logic in `core/domain/utils`, shared by Details and the update checker, with tests in `core/domain/src/commonTest` (start with `UpdateVerdictTest`). `AssetMatchingReplayTest` in `core/domain/src/jvmTest` replays a corpus of real repositories collected by `tools/asset-replay/`.

- **`AssetVariant`**: token, glob and stem fingerprints of an asset name, plus `versionMajor`. `tagGlob` blanks a name's own release version and adjacent build ids, so callers pass each name's release tag.
- **`AssetOwnership`**: which installed app owns an asset when a repository ships several apps. Filter first, then glob, then stem; ties go to the matching major version.
- **`assetPlatformOf`**: the platform an asset is for.
- **`newestReleasePerPlatform`**: the newest release for each platform, the device's platform first. Drives the Details platform chips.
- **`ReleaseLines`**: which app a tag belongs to in a monorepo (`auth-v4.4.25` belongs to `auth`). Renames and Details chips stay within a line.

`DetailsScreen.packageName`, set when Details opens from Apps, selects the installed app in a multi-app repository.

In the Library, an update or re-download starts from the match the last update check stored on the installed app (`latestReleaseId` and the `latestAsset*` fields). A fresh release lookup runs only when nothing is stored or a download from the stored match failed (`AppsViewModel.resolveUpdateTarget`).

---

### Agents Sign In (`docs/agents/sign-in.md`)

# Sign-in and the backend

## GitHub OAuth

`GITHUB_CLIENT_ID` comes from `local.properties`; without the key, the build uses the built-in client id (`BuildKonfigConventionPlugin`). An empty value overrides it and breaks sign-in. Deep links: `githubstore://auth` (web-OAuth handoff), `githubstore://callback` (leftover from the device flow), `githubstore://repo`, `githubstore://apps`; the full set is in `DeepLinkParser.kt`.

## Sign-in flow (web OAuth first)

1. **Web OAuth with PKCE and a handoff.** `feature/auth/data/crypto/PkceGenerator` mints `(state, codeVerifier, codeChallenge)`. `WebAuthApi.register` POSTs verifier, challenge and state to `https://github-store.org/auth/register` (a Cloudflare Worker stores them in Workers KV) and returns `authUrl`. The user authorizes on `github.com`, GitHub redirects to `github-store.org/auth/callback?code&state`, and the Worker exchanges the code via `api.github-store.org`. The backend stores `(handoffId -> access_token)` for 60 s in Postgres with an atomic `DELETE ... RETURNING`, then bounces back to `githubstore://auth?handoff=<handoffId>&state=<state>`. The app reads the handoff with `WebAuthApi.consumeHandoff` (GETDEL semantics).
2. **Device flow** through the backend's `/v1/auth/device/start` and `/poll`. `AuthPath` (`Backend` or `Direct`) is kept in `SavedStateHandle` and only escalates from `Backend` to `Direct` on infrastructure errors.
3. **Personal access token** pasted by the user (`signInWithPat`): validated against `/user`, and saved optimistically when GitHub is unreachable.

Backend rate limits: 10 device starts and 200 device polls per hour per IP. Endpoints are in `core/data/network/BackendEndpoints.kt` (`BACKEND_ORIGIN`, `WEB_ORIGIN`).

## The `X-GitHub-Token` header

The client attaches the user's token as `X-GitHub-Token` when `TokenStore.currentToken()` is non-null on `/v1/search`, `/v1/search/explore`, `/v1/repo` (including `POST .../refresh`), `/v1/releases`, `/v1/readme`, `/v1/user` and `/v1/users/{user}/repos`. The backend re-sends it as `Authorization: token $token` to GitHub; without it, the backend round-robins a pool of 4 service tokens.

- An upstream 401 comes back as a backend `502` and is treated like "GitHub unreachable": the client falls back through `shouldFallbackToGithubOrRethrow`.
- A `429` gets no fallback (the same limit applies), only backoff.
- `UnauthorizedInterceptor` sits only on the direct-GitHub client; `UserSessionRepositoryImpl` debounces consecutive 401s by token snapshot.

---

### Agents Translation (`docs/agents/translation.md`)

# Translation providers

The `TranslationProvider` enum has `GOOGLE`, `YOUDAO`, `LIBRE_TRANSLATE`, `DEEPL` and `MICROSOFT`. Each provider's settings are stored through `TweaksRepository` (KSafe-encrypted), and `TranslationRepositoryImpl.resolveTranslator()` picks the implementation.

- **LibreTranslate** uses the bundled `translate.disroot.org` mirror when the user's URL setting is blank.
- **DeepL** sends keys ending in `:fx` to `api-free.deepl.com`.
- **Microsoft** uses No-Trace by default: text is never stored and never used for training.

---

### Quality Findings (`docs/quality/findings.md`)

# Code quality research: Komi Store and kotlinconf-app

Status: decisions made on 2026-10-19 (section 5). The rules they produce live in [`docs/code-style.md`](../code-style.md); this file keeps the evidence.

- Komi Store: `origin/main` at [`cfa6bd30`](https://github.com/komi-store/komi-store/tree/cfa6bd30e6b35cbbc369d78a4599182a9a66beac) (2026-10-18). Links into our code are permalinks to that commit, so they stay correct as the code moves.
- kotlinconf-app: [`2189155`](https://github.com/JetBrains/kotlinconf-app/tree/218915583d0941e5cf67b839280fef79400e7b20), links are pinned to it.

## How this was made

- 8 read-only research agents walked both codebases on the same 15 dimensions: 2 on kotlinconf-app (non-UI and UI), 6 covering all 13 Komi features, `core/*`, `composeApp`, `build-logic` and CI.
- Lint numbers come from actually running ktlint 1.8.0 with the Compose rules on all 37 modules in a scratch worktree (section 2).
- Every link was checked twice. A script confirmed that each file and line exists, then three verifier agents re-read all 277 citations against the code: 259 were right as written and 18 were corrected. The bugs in section 3 marked "verified" were also re-read by hand.
- Agents can still misread code. Treat a single citation as a pointer, not proof, until someone looks.

## TL;DR

1. **kotlinconf-app is a source of techniques, not a quality bar.** It has no lint, two client test files, a 587-line god service, Boolean flags for one-off events and no stateless screen layer. Copying it wholesale would lower our bar in several places (section 1).
2. **Our biggest problem is internal drift, not missing kotlinconf patterns.** Five ViewModel shapes, three ways to navigate, three repository error contracts, no rule for user-facing text, and `CancellationException` swallowed in dozens of places (section 7).
3. **Lint covers 1 of 37 modules.** With ktlint on everywhere, hand-written code has 6,184 violations. A one-time `ktlintFormat` fixes all but 201 of them and touches 551 files (section 2).
4. **The CLAUDE.md files describe code that no longer exists**: routes, repositories, components, injected dependencies, cache TTLs. Agents read them on every task. Fixing the docs is the cheapest high-leverage step (section 8).
5. **The research found real bugs**, listed apart from style in section 3: per-host access tokens are never sent (regression since #666), the Desktop database is wiped on the next schema bump, a rate-limit exception can escape into `viewModelScope`, and more.

## 1. What kotlinconf-app is, next to us

| Aspect | kotlinconf-app | Komi Store |
|---|---|---|
| Modules | `:core`, `:app:ui-components`, one `:app:shared` with every screen ([settings.gradle.kts:35-44](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/settings.gradle.kts#L35-L44)) | 37 modules, features split into domain / data / presentation |
| Build logic | none; the Android block is copied in 3 build files ([build.gradle.kts:25-31](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/build.gradle.kts#L25-L31)) | 9 convention plugins in `build-logic` |
| DI | Metro 1.4, compile-time graphs ([YearGraph.kt:10-22](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/di/YearGraph.kt#L10-L22)) | Koin 4.1 |
| Navigation | Navigation 3 with per-tab back stacks ([NavState.kt:22-28](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/NavState.kt#L22-L28)) | Navigation Compose 2.9, one graph ([GithubStoreGraph.kt:5](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt#L5)) |
| ViewModel API | several `StateFlow`s plus public methods ([ScheduleViewModel.kt:111](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleViewModel.kt#L111)) | mostly `State` + `onAction(sealed)` + `Channel` events |
| One-off events | Boolean `StateFlow` plus a reset call ([FeedbackViewModel.kt:37-38](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/FeedbackViewModel.kt#L37-L38)) | `Channel` + `ObserveAsEvents` ([ObserveAsEvents.kt:13](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/ObserveAsEvents.kt#L13)) |
| Loading and error UI | one generic `ErrorLoadingState` and one renderer ([ErrorLoadingContent.kt:21-53](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/ErrorLoadingContent.kt#L21-L53)) | nothing shared; about 19 feature-local Loading/Error/Empty composables |
| Screen structure | screen takes the ViewModel as a default parameter, no stateless layer ([ScheduleScreen.kt:87-93](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt#L87-L93)) | `XRoot` + stateless `XScreen(state, onAction)` in most features |
| Design system | custom, built on foundation, no Material ([KotlinConfTheme.kt:41-56](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/theme/KotlinConfTheme.kt#L41-L56)) | custom `Personality` system on top of Material 3 |
| Previews | 79, all in ui-components, none for screens | 75 in core/presentation, a handful in features |
| Static analysis | none | ktlint on `composeApp` only |
| Tests | 2 client test files, 242 lines | 35 test files, strong pure-logic tests, no ViewModel tests |
| CI | builds every platform, runs `jvmTest` ([build-all.yml:25-27](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/.github/workflows/build-all.yml#L25-L27)) | packaging only, nothing runs on pull requests |

What to take from it is a handful of specific techniques (section 4), not its architecture. Several of its own weak spots are listed by the agents: a god service that ViewModels depend on concretely ([ConferenceService.kt:46](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/ConferenceService.kt#L46)), ViewModels passed into child composables ([ScheduleScreen.kt:311](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt#L311)), a global navigation channel ([NavHost.kt:91](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/NavHost.kt#L91)), and empty results shown as errors ([ScheduleViewModel.kt:166-167](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleViewModel.kt#L166-L167)).

## 2. Measurements

### Lint

| What | Number |
|---|---|
| Modules ktlint runs on today | 1 of 37 (`composeApp`, through [AndroidApplicationComposeConventionPlugin.kt:13](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt#L13)) |
| Violations in hand-written code, ktlint on everywhere | **6,184** in 588 files |
| Top rules | multiline-expression-wrapping 1,335; function-signature 898; indent 622; chain-method-continuation 395; when-entry-bracing 393; class-signature 329 |
| Worst modules | core/data 1,365; feature/tweaks 850; core/domain 744; feature/apps 707; feature/details 662 |
| Diff of a one-time `ktlintFormat` | **551 files, +16,094 / -12,334 lines** |
| Left after that format | **201** in 132 files: 170 Compose rules, 31 standard rules |
| Is the format safe? | Yes: a trial run compiled for JVM and Android debug and all 411 JVM tests passed |
| What the 201 are | compose:modifier-missing-check 60, compose:parameter-naming 35, compose:param-order-check 23, standard:package-name 13 (snake_case packages), compose:lambda-param-in-effect 10, others under 10 each |

Run details: ktlint 1.8.0 (already pinned in [KtlintConventionPlugin.kt:11](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/build-logic/convention/src/main/kotlin/KtlintConventionPlugin.kt#L11)), the project `.editorconfig`, plus `io.nlopez.compose.rules:ktlint:0.6.0`.

Three things came out of making the run work. The first two concern ktlint-gradle, which [#1056](https://github.com/komi-store/komi-store/pull/1056) replaces with Spotless; the third still decides which Compose rules version works:

1. **The generated-code filter does not work.** [KtlintConventionPlugin.kt:15](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/build-logic/convention/src/main/kotlin/KtlintConventionPlugin.kt#L15) excludes `**/generated/**`, but ktlint-gradle matches patterns relative to each source root, so KSP Room code and Compose resource accessors were linted too: 104,706 extra violations from generated files. Excluding by absolute path (`it.file.invariantSeparatorsPath.contains("/build/")`) fixes it.
2. **The format-on-build hook breaks once ktlint is on everywhere.** [build.gradle.kts:21-34](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/build.gradle.kts#L21-L34) makes `preBuild` and compile depend on `ktlintFormat`. In `core:data` that creates a task cycle through KSP (`preBuild -> ktlintFormat -> kspDebugKotlinAndroid -> preDebugBuild`). It also rewrites sources during a compile.
3. **compose-rules 0.6.1 and later crash on ktlint 1.8.0.** They are built with Kotlin 2.4 and call a stdlib function the ktlint worker does not have (`NoSuchMethodError: sequenceOf`). 0.6.0 is the newest release built on Kotlin 2.2.21 and runs fine.

### Detekt

Measured on `cfa6bd30` with detekt 1.23.8:

| What | Number |
|---|---|
| Default config | **3,040** findings in 467 files. MagicNumber 785, FunctionNaming 626 (composables are PascalCase), TooGenericExceptionCaught 332, LongMethod 244 |
| A Compose-aware config (prototype, not committed) | **1,008** in 223 files. MagicNumber 463 (non-UI code), ReturnCount 88, LongMethod 83, CyclomaticComplexMethod 80, SwallowedException 26, EmptyCatchBlock 4, PrintStackTrace 4 |
| Longest functions | the NavHost lambda in [AppNavigation.kt:92](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt#L92) (899 lines), `TweaksViewModel.onAction` [TweaksViewModel.kt:561](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt#L561) (822), [preprocessMarkdown.kt:3](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/utils/preprocessMarkdown.kt#L3) (408), [DetailsRoot.kt:106](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt#L106) (396), [SearchRoot.kt:237](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt#L237) (383), [AppsRoot.kt:214](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt#L214) (355) |

Detekt 1.23.8 is built on Kotlin 2.0.21; running it in our Gradle build needs its Kotlin dependencies pinned to that version. [#1056](https://github.com/komi-store/komi-store/pull/1056) runs it in CI with per-module baselines, so only new findings fail a PR (D12).

### Tests, CI, review

| What | Number |
|---|---|
| Test files | 35. core/domain has 289 tests (`UpdateVerdictTest` alone 60, plus a 48-project corpus replay); core/data 11 files, mostly downloads; core/presentation 2; feature/apps/presentation 2; feature/details/domain 1 |
| ViewModel tests | 0 |
| Test libraries in the version catalog | none; modules add `kotlin("test")` one by one ([build.gradle.kts:15](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/build.gradle.kts#L15)) |
| CI that runs on pull requests | none at `cfa6bd30` ([build-desktop-platforms.yml:3-6](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/.github/workflows/build-desktop-platforms.yml#L3-L6) runs on pushes to `generate-installers`); `pr-checks.yml` ([#1056](https://github.com/komi-store/komi-store/pull/1056)) now compiles JVM and Android debug, runs `jvmTest` with coverage, Spotless, detekt, Android Lint, the Compose stability check and Konsist |
| What CONTRIBUTING says | "CI lints every PR" ([CONTRIBUTING.md:153](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/CONTRIBUTING.md#L153)) and "No CI runs against PRs" ([CONTRIBUTING.md:251](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/CONTRIBUTING.md#L251)) |
| CodeRabbit | dropped on 2026-10-19 and its config removed. It used to read every `**/CLAUDE.md` as review guidelines, so stale docs misled it too. |

### Size hot spots

| File | Lines |
|---|---|
| [DetailsViewModel.kt](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt) | 3,278 (22 dependencies, an `onAction` of about 400 lines) |
| [AppsViewModel.kt](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt) | 2,446 (12 dependencies, 67 action types in one `when`) |
| [TweaksViewModel.kt](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt) | 1,722 (86 `launch` calls) |
| [DesktopInstaller.kt](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt) | 1,387 |
| [SearchRoot.kt](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt) | 1,119 |
| [AppNavigation.kt](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt) | 1,035 (every destination inline in one NavHost) |

## 3. Bugs and risks found on the way

Not style. Each deserves an issue or a small fix PR of its own, separate from the style work.

| # | What | Where | Status |
|---|---|---|---|
| 1 | **Per-host access tokens are never sent.** `HostTokenInterceptor` was added in #634 (`aa5de4c0`) and deleted in #666 (`c94f50b2`). Since then `HostTokenRepository` is read only by the Tweaks screen and the Forgejo client sets no `Authorization` header. Users who add a Codeberg/Forgejo token get no effect. | [HostTokensViewModel.kt:28](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensViewModel.kt#L28), [ForgejoApiClient.kt:52-56](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ForgejoApiClient.kt#L52-L56) | verified (git history + grep) |
| 2 | **Desktop wipes its database on the next schema bump.** Desktop uses `fallbackToDestructiveMigration(true)` with only `MIGRATION_19_20` registered. Android registers the full chain. | [initDatabase.kt:22-23](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt#L22-L23) | verified |
| 3 | **A rate-limit exception escapes into `viewModelScope`.** `getIssues` returns `Result` but rethrows `RateLimitException`; `IssuesViewModel` calls it in `launch` with no catch, so the exception goes to the default handler (a crash on Android). | [RepoPagesRepositoryImpl.kt:75-76](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/repo-pages/data/src/commonMain/kotlin/zed/rainxch/repopages/data/repository/RepoPagesRepositoryImpl.kt#L75-L76), [IssuesViewModel.kt:138-142](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/repo-pages/presentation/src/commonMain/kotlin/zed/rainxch/repopages/presentation/issues/IssuesViewModel.kt#L138-L142) | verified |
| 4 | **"Update all failed" shows an array identity.** `getString(Res.string.update_all_failed, arrayOf(e.message))` passes the array as one format argument. | [AppsViewModel.kt:1534-1537](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L1534-L1537) | verified |
| 5 | **Most Tweaks events are dropped.** Events not listed fall into `else -> Unit`; 11 of the 14 event types are dropped, including proxy save and proxy test errors, so the user gets no feedback. | [TweaksRoot.kt:108](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt#L108) | verified |
| 6 | **Developer profiles never show as organizations when loaded through the backend.** `isOrganization` is derived from `userType`, which the mapper never sets. | [UserProfileNetworkToDomain.kt:6-24](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/UserProfileNetworkToDomain.kt#L6-L24), [DeveloperProfile.kt:20-22](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/DeveloperProfile.kt#L20-L22) | verified |
| 7 | **Search load-more may read stale flags.** `remember { derivedStateOf { ... state.isLoadingMore ... } }` has no key and captures the first `state` parameter. | [SearchRoot.kt:246-272](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt#L246-L272) | likely; needs a device check |
| 8 | **The same platform filter behaves differently.** Feed sends `"ios"` for iOS, Home sends nothing. | [FeedRepositoryImpl.kt:148](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/feed/data/src/commonMain/kotlin/zed/rainxch/feed/data/repository/FeedRepositoryImpl.kt#L148), [CachedRepositoriesDataSourceImpl.kt:467](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt#L467) | verified |
| 9 | **Sign-in ignores the user's proxy.** `GitHubAuthApi` builds its own `HttpClient {}` with no proxy configuration, unlike the injected clients. | [GitHubAuthApi.kt:37-53](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/network/GitHubAuthApi.kt#L37-L53) | verified |
| 10 | The auth ViewModel collects the token flow on the injected app scope, so the collector outlives the ViewModel. | [AuthenticationViewModel.kt:41](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt#L41), [AuthenticationViewModel.kt:64](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt#L64) | reported |
| 11 | Each Retry on Details starts another untracked `observeInstalledApp` collector. | [DetailsViewModel.kt:1197](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt#L1197) | reported |
| 12 | The developer-profile filter launches a new job per keystroke without cancelling the old one, so stale results can win. | [DeveloperProfileViewModel.kt:144](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileViewModel.kt#L144) | reported |
| 13 | Every GitHub request runs `runBlocking` to read the token. | [HttpClientFactory.kt:91](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt#L91), [DefaultTokenStore.kt:68-69](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt#L68-L69) | reported |
| 14 | `BootReceiver` calls `runBlocking` on the main thread after `goAsync()`. | [BootReceiver.kt:15-19](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/BootReceiver.kt#L15-L19) | reported |
| 15 | The external-installer dialog is unreachable: `showExternalInstallerPrompt` is never set to true. | [RawDetailsState.kt:77](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/RawDetailsState.kt#L77), [DetailsRoot.kt:478](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt#L478) | reported |
| 16 | 19 string keys are missing in 11 locales (10 in the two Chinese ones), and `whatsnew/22.json` exists in only 2 of 14 locale folders. | [strings.xml:164](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/composeResources/values/strings.xml#L164) (`pause`) | reported |

Fixes: bug 1 in [#1063](https://github.com/komi-store/komi-store/pull/1063), bug 2 in [#1062](https://github.com/komi-store/komi-store/pull/1062), bugs 3 and 4 in progress; bugs 5 to 9 are to be filed as issues.

## 4. Adopt: cheap, clear wins

These need no big decision. Each says what kotlinconf does, where we stand, and the proposed rule.

### A1. Never swallow cancellation

- kotlinconf rethrows `CancellationException` before every broad catch ([ApplicationApi.kt:47-51](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/network/ApplicationApi.kt#L47-L51)).
- We do it in most places too ([DetailsViewModel.kt:208-212](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt#L208-L212), [BackendFallbackPolicy.kt:8-14](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendFallbackPolicy.kt#L8-L14), [KSafeSafe.kt:13-21](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/secure/KSafeSafe.kt#L13-L21)), but `runCatching` around suspend calls undoes it: 120 `runCatching` in core/data alone. Examples that swallow cancellation: [SlowDownloadDetectorImpl.kt:78](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt#L78) (wraps `safeGet`, which rethrows on purpose), [ExternalImportRepositoryImpl.kt:76-82](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt#L76-L82), [SearchViewModel.kt:538-539](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt#L538-L539), [GitHubAuthApi.kt:164](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/network/GitHubAuthApi.kt#L164), [HostTokensViewModel.kt:208](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensViewModel.kt#L208), [AppsViewModel.kt:1528](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L1528), [UpdateCheckWorker.kt:83](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt#L83).
- Rule: no `runCatching` around suspend calls; use one shared `suspendRunCatching` helper that rethrows `CancellationException`. Broad catches rethrow cancellation first.
- Enforce: a Konsist rule with an allowlist (follow-up).

### A2. One shared load / error / empty renderer

- kotlinconf: one sealed `ErrorLoadingState<T>` and one `ErrorLoadingContent` that waits 100 ms before showing loading or error (no flash on fast loads), cross-fades and offers retry ([ErrorLoadingContent.kt:35-53](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/ErrorLoadingContent.kt#L35-L53)). Its smaller error component also announces itself with `liveRegion` ([ErrorLoading.kt:76-78](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/ErrorLoading.kt#L76-L78)); ours should do that for every error state.
- We have none in core and about 19 local copies: [HomeRoot.kt:350](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt#L350), [FeedRoot.kt:351](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/feed/presentation/src/commonMain/kotlin/zed/rainxch/feed/presentation/FeedRoot.kt#L351), [RepoPagesError.kt:23](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/repo-pages/presentation/src/commonMain/kotlin/zed/rainxch/repopages/presentation/components/RepoPagesError.kt#L23), [DeveloperProfileRoot.kt:228-272](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt#L228-L272), [StarredReposRoot.kt:212-259](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposRoot.kt#L212-L259).
- Rule: new screens use a shared `KomiLoadState` renderer in core/presentation; old ones move when touched. Keep our `Personality` look.
- Do not copy kotlinconf's "empty means error" ([ScheduleViewModel.kt:166-167](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleViewModel.kt#L166-L167)); empty is its own state.

### A3. Resolve user text at render; never show `e.message`

- kotlinconf ViewModels expose values (minutes as `Int?`), and composables format them ([ScheduleScreen.kt:545-547](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt#L545-L547)). We already do this in one place: typed `TestResult` mapped at render ([MirrorPickerRoot.kt:240-245](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerRoot.kt#L240-L245)), though its fallback case still shows exception text ([MirrorPickerRoot.kt:246](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerRoot.kt#L246)).
- Elsewhere ViewModels resolve strings and fall back to exception text: [DetailsViewModel.kt:3009](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt#L3009), [IssuesViewModel.kt:112](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/repo-pages/presentation/src/commonMain/kotlin/zed/rainxch/repopages/presentation/issues/IssuesViewModel.kt#L112), [HomeViewModel.kt:290](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt#L290), [ExternalImportViewModel.kt:294](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt#L294), [AuthenticationViewModel.kt:776-797](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt#L776-L797). English exception text then reaches the UI, from the data layer ([RepoPagesRepositoryImpl.kt:333-338](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/repo-pages/data/src/commonMain/kotlin/zed/rainxch/repopages/data/repository/RepoPagesRepositoryImpl.kt#L333-L338) into toasts, [DeeplTranslator.kt:61](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/DeeplTranslator.kt#L61) into the translation card) and from ViewModels themselves ([AppsViewModel.kt:1233](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L1233)). `ErrorState` even classifies errors by searching the (maybe translated) message for "404" or "rate limit" ([ErrorState.kt:194-207](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/states/ErrorState.kt#L194-L207)).
- Rule: state holds typed errors; composables map them to strings. No `e.message` in UI. The exact shape is decision D2.

### A4. Accessibility semantics in shared components

- kotlinconf: `heading()` on titles, `liveRegion` on errors and counts, `stateDescription` and `Role` on toggles, `clearAndSetSemantics` on child toggles inside a toggleable row ([SettingsItem.kt:36-78](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/SettingsItem.kt#L36-L78), [TalkCard.kt:222-240](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/TalkCard.kt#L222-L240)).
- Ours: hardcoded English content descriptions in core components ([KomiChip.kt:255](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/KomiChip.kt#L255), [KomiTextField.kt:361](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/inputs/KomiTextField.kt#L361), [KomiSheet.kt:401](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/KomiSheet.kt#L401), [KomiToast.kt:272](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/KomiToast.kt#L272)).
- Rule: every user-visible string in core, content descriptions included, comes from resources; toggles and selectable rows set `Role` and `stateDescription`.

### A5. Previews where they pay off

- kotlinconf: one preview wrapper ([PreviewHelper.kt:13-31](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/theme/PreviewHelper.kt#L13-L31)) with androidx's `@PreviewLightDark` (77 previews) and one custom wide multipreview (3 previews, [MultiPreviews.kt:7-11](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/utils/MultiPreviews.kt#L7-L11)) make 79 previews cheap; a fake `InteractionSource` previews hover and focus ([PreviewInteractionSource.kt:9-17](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/utils/PreviewInteractionSource.kt#L9-L17)). It has zero screen previews because it has no stateless screens.
- Ours: the `PersonalityPreview` wrapper ([PersonalityPreview.kt:16](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/personality/utils/PersonalityPreview.kt#L16)) renders one personality per call, and core components write one preview per personality: 75 previews in core. But 25 core component files have none, and features have 0 to 4 each (feed, home and recently-viewed have none). Our `XScreen(state, onAction)` split lets us preview whole screens, which kotlinconf cannot.
- Rule: every public core component and every stateless `XScreen` gets at least one preview with fake state. Add the hover/focus trick for desktop.

### A6. Power-assert for tests

- kotlinconf turns on the Kotlin power-assert compiler plugin for `assertEquals` / `assertTrue` ([build.gradle.kts:147-156](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/build.gradle.kts#L147-L156)), so failures print the values involved with no assertion library.
- Rule: apply it to test source sets in a convention plugin.

### A7. No wildcard imports

- kotlinconf sets the IDE star-import threshold to 1024 ([.editorconfig:2-3](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/.editorconfig#L2-L3)).
- We disable ktlint's `no-wildcard-imports` ([.editorconfig:5](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/.editorconfig#L5)) and mix styles: wildcard `res.*` in [AppsViewModel.kt:73](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L73) and [TweaksRoot.kt:47](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt#L47), explicit imports in most files.
- Rule: re-enable `no-wildcard-imports`, set the same IDE thresholds.

### A8. Typed route arguments, no positional `parametersOf`

- kotlinconf passes route arguments through typed assisted factories ([DocumentsViewModel.kt:54-59](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/DocumentsViewModel.kt#L54-L59)).
- We pass up to six positional parameters and read them by index: [ViewModelsModule.kt:47-52](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt#L47-L52) against [AppNavigation.kt:479-489](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt#L479-L489). A default `koinViewModel()` with no params then hits `params[0]` ([DetailsRoot.kt:115](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt#L115)).
- Rule: ViewModels with route arguments read the route object (`SavedStateHandle.toRoute<...>()`) or one typed params class.

### A9. Short, true CLAUDE.md files

- kotlinconf's [CLAUDE.md](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/CLAUDE.md) lists modules, commands and conventions that match the code (it only skips the small admin app module).
- Ours describe things that are gone (section 8). Rule: CLAUDE.md files state only what a reader cannot get from the code in a minute, and are fixed in the same PR that changes the thing they describe.

### A10. Pin every GitHub Action by commit SHA

- We already pin SignPath ([build-desktop-platforms.yml:158](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/.github/workflows/build-desktop-platforms.yml#L158)) but use `@main` for winget ([winget-publish.yml:12](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/.github/workflows/winget-publish.yml#L12)) and tags elsewhere. kotlinconf uses tags everywhere.
- Rule: all third-party actions pinned by SHA with the version in a comment.

### Worth knowing, not rules

- A scoped DI sub-graph rebuilt when a key changes ([YearGraph.kt:10-19](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/di/YearGraph.kt#L10-L19)).
- A logger that buffers until the real logging policy is known ([BufferedDelegatingLogger.kt:51-64](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/BufferedDelegatingLogger.kt#L51-L64)).
- Tab reselect as a `SharedFlow` that screens use to scroll to top ([Navigator.kt:54-64](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/Navigator.kt#L54-L64)).
- A slot-based compact / medium / large detail layout ([AdaptiveDetailLayout.kt:29-38](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/AdaptiveDetailLayout.kt#L29-L38)), relevant for desktop.
- `@SerialName` on every route so renames don't break saved back stacks ([Routes.kt:17-19](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/Routes.kt#L17-L19)).
- Explicit backing fields (`val x: StateFlow<T>` + `field = MutableStateFlow(...)`) replace the `_state` pair ([FeedbackViewModel.kt:34-35](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/FeedbackViewModel.kt#L34-L35)). kotlinconf compiles them on Kotlin 2.4.10 with no flag; we are on 2.3.10, so check availability before adopting.

## 5. Decide: your call

Decided on 2026-10-19: option A for D1 to D11. D12 was overtaken: CodeRabbit is no longer used, detekt runs in CI with baselines through [#1056](https://github.com/komi-store/komi-store/pull/1056), and the mechanical rules move to Konsist.

### D1. One ViewModel shape

Today, at least five shapes exist:

| Shape | Examples |
|---|---|
| `_state` + `onStart { if (!hasLoaded) load() }` + `stateIn(WhileSubscribed(5_000))` + `onAction` + `Channel` | [AppsViewModel.kt:113-128](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L113-L128), [DetailsViewModel.kt:174-192](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt#L174-L192), [TweaksViewModel.kt:93-128](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt#L93-L128), 11 ViewModels in total |
| `asStateFlow()` + `init { load() }` (the shape root CLAUDE.md documents) | [ProfileViewModel.kt:21-33](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt#L21-L33), [CategoryListViewModel.kt:30](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListViewModel.kt#L30), [IssuesViewModel.kt:37-42](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/repo-pages/presentation/src/commonMain/kotlin/zed/rainxch/repopages/presentation/issues/IssuesViewModel.kt#L37-L42) |
| public methods or bare state, no Action or Event types | [DetailsAboutViewModel.kt:37-126](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/about/DetailsAboutViewModel.kt#L37-L126), [AppInfoViewModel.kt:8-12](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/appinfo/AppInfoViewModel.kt#L8-L12), [WhatsNewViewModel.kt:24-31](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/whatsnew/WhatsNewViewModel.kt#L24-L31) |
| `onAction` but no events | [PullsViewModel.kt](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/repo-pages/presentation/src/commonMain/kotlin/zed/rainxch/repopages/presentation/pulls/PullsViewModel.kt), [SecurityViewModel.kt](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/repo-pages/presentation/src/commonMain/kotlin/zed/rainxch/repopages/presentation/security/SecurityViewModel.kt) |
| several public flows | [AnnouncementsViewModel.kt:38-64](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/announcements/AnnouncementsViewModel.kt#L38-L64) |

- **Option A (recommended):** the dominant first shape. State loads when the screen subscribes, not when the ViewModel is created, and it is already in 11 ViewModels. Name the `5_000` once. Add a Konsist test: every `XViewModel` exposes `state: StateFlow<XState>` and `fun onAction(action: XAction)`.
- Option B: kotlinconf's several flows plus methods. Less boilerplate for tiny screens, but it splits the API and pushes one-off events into Boolean flags.
- Option C: keep the documented `asStateFlow() + init` shape. Simpler, but it loads on creation and would mean changing the 11 ViewModels that already use A.

### D2. Error contract and user text

Repositories use three contracts today: `kotlin.Result` (Feed [FeedRepositoryImpl.kt:54](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/feed/data/src/commonMain/kotlin/zed/rainxch/feed/data/repository/FeedRepositoryImpl.kt#L54), repo-pages [RepoPagesRepository.kt:16](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/repo-pages/domain/src/commonMain/kotlin/zed/rainxch/repopages/domain/repository/RepoPagesRepository.kt#L16)), thrown exceptions (Details [DetailsRepository.kt:13](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt#L13)), and silently emitting empty results (Home [HomeRepositoryImpl.kt:477-486](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt#L477-L486)). One interface mixes `Result` with plain throwing calls ([AuthenticationRepository.kt:10-23](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/domain/src/commonMain/kotlin/zed/rainxch/auth/domain/repository/AuthenticationRepository.kt#L10-L23)).

- **Option A (recommended):**
  - Fallible repository calls return `Result<T>`, and failures are typed domain exceptions. `RateLimitException` already exists; add NotFound and Offline.
  - Cancellation is never caught.
  - ViewModels keep a typed error in state, and composables map it to a string resource. We already have one good model to copy: [ProxyTestOutcome.kt:3-27](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/ProxyTestOutcome.kt#L3-L27).
- Option B: a shared `UiText` (resource plus arguments, or a raw string) that ViewModels put in state. A common Android pattern, but it keeps string decisions in ViewModels and still invites `UiText.Raw(e.message)`.
- Option C: a project-wide sealed `DataError` hierarchy with its own `Result` type. The most type-safe, and the largest migration.

### D3. One navigation rule

Three routes exist, sometimes on one screen:

- **Root intercepts the action** and the ViewModel keeps an empty branch: [HomeRoot.kt:102](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt#L102) with [HomeViewModel.kt:212](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt#L212), and [DetailsRoot.kt:161-181](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt#L161-L181).
- **ViewModel sends a Navigate event:** [StarredPickerViewModel.kt:72](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/starred/StarredPickerViewModel.kt#L72), [CategoryListViewModel.kt:62](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListViewModel.kt#L62).
- **Direct callbacks with dead ViewModel branches:** [TweaksViewModel.kt:561-573](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt#L561-L573).

On Apps, back and starred go one way and repo navigation the other ([AppsRoot.kt:150-156](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt#L150-L156) against [AppsViewModel.kt:453-465](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L453-L465)).

- **Option A (recommended):** clicks that only navigate are plain lambdas on the Screen (`onNavigateToDetails`), not Actions. The ViewModel emits a `Navigate*` event only when navigation depends on async work, for example after sign-in. No navigation cases inside Action types, so no `-> Unit` branches.
- Option B: keep "Root intercepts the Action" everywhere. Least churn, but every navigation Action needs an empty ViewModel branch forever.

### D4. How to roll ktlint out

Measured in section 2:

- **Option A (recommended):**
  - One mechanical `ktlintFormat` PR at a quiet moment: 551 files, compile-checked in a trial run.
  - Add its commit to `.git-blame-ignore-revs`.
  - Fix or baseline the 201 that remain, then fail CI on new violations.
  - Cost: open PRs and volunteer branches need a rebase.
- Option B: a ktlint baseline of all 6,184 violations and no mass format. ktlint baselines match on line and column, so any edit that shifts lines re-surfaces old violations in touched files. That causes noise for months.
- Option C: format module by module over several PRs. Smaller rebases, but CI can only gate modules that are done.
- Since then [#1056](https://github.com/komi-store/komi-store/pull/1056) runs ktlint through Spotless on files changed against `main`, so formatting blocks from day one without baselines. The one-time format still matters: until it lands, touching a file means reformatting all of it in that PR.

### D5. Remove the format-on-build hook

- [build.gradle.kts:21-34](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/build.gradle.kts#L21-L34) rewrites sources during compile, hides violations from CI, and creates a task cycle in KSP modules once ktlint is on everywhere (section 2).
- **Recommended:** remove it. [#1056](https://github.com/komi-store/komi-store/pull/1056) does, and formats through `spotlessApply` with an opt-in pre-push hook.

### D6. Compose lint rules

- 170 findings after the format pass. Most are cheap: a missing `modifier` parameter on screens and components (60, for example [DetailsRoot.kt:530](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt#L530)), parameter naming (35), parameter order (23).
- **Recommended:** on, fix the cheap ones in the same rollout, and turn off by `.editorconfig` any rule we disagree with (for example `compositionlocal-allowlist`, given our `Local*` design).

### D7. One logging style

- core/data has about 366 static Kermit `Logger.` calls and 21 calls on the injected `KomiStoreLogger`, sometimes in the same class ([UserSessionRepositoryImpl.kt:107](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/UserSessionRepositoryImpl.kt#L107) against [UserSessionRepositoryImpl.kt:177](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/UserSessionRepositoryImpl.kt#L177)). About half of the error logs interpolate `.message` and drop the stack trace ([InstalledAppsRepositoryImpl.kt:167](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt#L167)).
- **Recommended:** the injected `KomiStoreLogger` everywhere, with the throwable always passed, never `.message`.

### D8. A size ratchet for god classes

- **Recommended:**
  - A Konsist test that fails when a production file grows past 800 lines.
  - An allowlist of today's offenders (section 2) that can only shrink.
  - Split along existing seams when touched. Examples: Details download/install/translation, Apps update/import/link, the four copies of proxy-config code in [TweaksViewModel.kt:684-726](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt#L684-L726), [TweaksViewModel.kt:1512-1530](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt#L1512-L1530), [TweaksViewModel.kt:1600-1628](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt#L1600-L1628) and [TweaksViewModel.kt:1639-1674](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt#L1639-L1674).
- No big-bang refactors.

### D9. Stability annotations

- Strong skipping is the Compose compiler default on our Kotlin version. kotlinconf puts no stability annotations on state classes; its only `@Stable` is a state holder ([NotificationBar.kt:17](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/NotificationBar.kt#L17)), the same split recommended here.
- Ours are random: [HomeState.kt:10](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeState.kt#L10) is `@Stable`, [FeedState.kt:11](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/feed/presentation/src/commonMain/kotlin/zed/rainxch/feed/presentation/FeedState.kt#L11) `@Immutable`, the apps states have none.
- **Recommended:**
  - No `@Stable` or `@Immutable` on screen state classes. Keep `ImmutableList` in state, which we already do almost everywhere.
  - Keep `@Stable` on real state-holder classes like [KomiToastState.kt:8](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/KomiToastState.kt#L8).

### D10. Test stack and the first ViewModel tests

- No ViewModel tests and no coroutine test tooling exist today.
- **Recommended:**
  - Add `kotlinx-coroutines-test` and Turbine to the version catalog, with fakes over mocks as now.
  - Make one small feature the reference: starred (1,089 lines) or favourites (1,402).
  - Write its ViewModel tests, then copy the pattern.

### D11. Metro and Navigation 3

- **Recommended: not now.**
- Metro would mean migrating 33 ViewModels and about 100 Koin singletons. The real pain it would remove, positional `parametersOf`, is fixed more cheaply by A8.
- Navigation 3's per-tab back stacks are attractive for the bottom bar and desktop sidebar, but Navigation Compose 2.9 works today.
- Revisit both if DI bugs or tab-state complaints become real.

### D12. Detekt: review comments or a CI gate

- **Resolved:** CodeRabbit is no longer used. detekt runs in Gradle and CI with per-module baselines through [#1056](https://github.com/komi-store/komi-store/pull/1056) (option B), and the mechanical rules this research wanted become Konsist tests.

## 6. Keep ours

| What | Why it beats or matches kotlinconf |
|---|---|
| Convention plugins in `build-logic` | kotlinconf copies Android setup between modules ([build.gradle.kts:15-20](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/core/build.gradle.kts#L15-L20)) |
| Feature modules split into domain / data / presentation | keeps features apart; kotlinconf has one client module |
| `XRoot` + stateless `XScreen(state, onAction)` | enables screen previews and tests; kotlinconf passes ViewModels into children ([SessionScreen.kt:197](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SessionScreen.kt#L197)) |
| `Channel` events + `ObserveAsEvents` | kotlinconf's Boolean flags need manual resets and drop repeated values ([StartNotificationsViewModel.kt:40](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/StartNotificationsViewModel.kt#L40)) |
| `ImmutableList` in state | fine with strong skipping; kotlinconf uses plain `List` |
| One shared `strings.xml` with 14 locales | one place for translators; kotlinconf splits resources between two modules and duplicates 86 drawables |
| `Personality` design system with a public dispatcher and private Manga / Classic implementations | richer than kotlinconf's theme, and features have almost stopped using raw Material ([KomiButton.kt:70-123](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/KomiButton.kt#L70-L123)) |
| `modifier` as the first optional parameter on public core components | matches the Compose API guidelines; kotlinconf is inconsistent ([SpeakerCard.kt:32-33](https://github.com/JetBrains/kotlinconf-app/blob/218915583d0941e5cf67b839280fef79400e7b20/app/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/SpeakerCard.kt#L32-L33)). Three of our markdown composables still take no `modifier` ([AlertBlockQuote.kt:72](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/markdown/AlertBlockQuote.kt#L72), [ExpandableDetails.kt:50](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/markdown/ExpandableDetails.kt#L50), [SyntaxHighlightedCode.kt:41](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/markdown/SyntaxHighlightedCode.kt#L41)) |
| Pure logic tests | `UpdateVerdictTest` (60 tests) and the corpus replay go well beyond kotlinconf's 242 test lines |
| Koin and Navigation Compose 2.9 | see D11 |

## 7. Internal drift

This is where most of the work is. Grouped by theme; each group lists the main examples, not every site.

### ViewModels

- Five state shapes (D1). Loading starts in `init`, in `onStart`, or both: [DetailsViewModel.kt:199-201](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt#L199-L201), [AppsViewModel.kt:130-132](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L130-L132). One screen even starts through `onStart`, an `OnStart` action and a `LaunchedEffect` together ([ExternalImportViewModel.kt:79-83](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt#L79-L83), [ExternalImportViewModel.kt:96-98](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt#L96-L98), [ExternalImportRoot.kt:86-90](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt#L86-L90)).
- State built from mutable shadow `var`s plus a manual `rebuild()` instead of `combine`: [HomeViewModel.kt:64-86](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt#L64-L86), [FeedViewModel.kt:58-75](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/feed/presentation/src/commonMain/kotlin/zed/rainxch/feed/presentation/FeedViewModel.kt#L58-L75).
- Non-atomic `_state.value = _state.value.copy(...)` mixed with `_state.update` in one class: [DetailsViewModel.kt:535](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt#L535).
- Dead Actions never sent from UI: 13 in [TweaksAction.kt:16](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt#L16), five in [AuthenticationAction.kt:16-22](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt#L16-L22), [RecentlyViewedAction.kt:14](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/recently-viewed/presentation/src/commonMain/kotlin/zed/rainxch/recentlyviewed/presentation/RecentlyViewedAction.kt#L14), [FeedAction.kt:11-12](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/feed/presentation/src/commonMain/kotlin/zed/rainxch/feed/presentation/FeedAction.kt#L11-L12).
- Three near-identical translation ViewModels: [DetailsAboutViewModel.kt](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/about/DetailsAboutViewModel.kt), [DetailsWhatsNewViewModel.kt:48-131](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/whatsnew/DetailsWhatsNewViewModel.kt#L48-L131), [MarkdownViewerViewModel.kt:52-137](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdownviewer/MarkdownViewerViewModel.kt#L52-L137).

### State

- Flat data classes of 60 to 70 fields: [AppsState.kt:24-110](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt#L24-L110), [RawDetailsState.kt:25-97](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/RawDetailsState.kt#L25-L97), [TweaksState.kt:28-105](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt#L28-L105).
- Nullable fields and sentinel strings used as flags: `advancedFilterError = "invalid"` ([AppsViewModel.kt:880](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L880)), error codes in a `String?` ([TweaksState.kt:46](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt#L46)).
- Fields written but never read, or read but never written: `installError` / `downloadError` never rendered, `isInstalling` never set ([RawDetailsState.kt:50](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/RawDetailsState.kt#L50)), `isLoading` never true ([FavouritesState.kt:11](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesState.kt#L11), [RecentlyViewedState.kt:9](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/recently-viewed/presentation/src/commonMain/kotlin/zed/rainxch/recentlyviewed/presentation/RecentlyViewedState.kt#L9)).
- Details has the best pattern: a raw state mapped once into an `@Immutable` view state with derived fields ([RawDetailsStateMapper.kt:11](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/RawDetailsStateMapper.kt#L11)).

### Composables

- Root/Screen split missing: [ExternalImportRoot.kt:50](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt#L50), [HostTokensRoot.kt:105](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensRoot.kt#L105), [MirrorPickerRoot.kt:68](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerRoot.kt#L68), [HiddenRepositoriesRoot.kt:53](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hidden/HiddenRepositoriesRoot.kt#L53), [SkippedUpdatesRoot.kt:55](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/skipped/SkippedUpdatesRoot.kt#L55), [LicensesRoot.kt:44](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/licenses/LicensesRoot.kt#L44).
- No route-level screen takes a `modifier`, and several components don't either (60 `compose:modifier-missing-check` hits).
- Whole state objects passed into leaf components: [LinkAppBottomSheet.kt:66-69](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt#L66-L69), [SmartInstallButton.kt:91-99](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt#L91-L99).
- Business logic in composables: version math in [SmartInstallButton.kt:105-177](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt#L105-L177), markdown preprocessing on `Dispatchers.Default` in [About.kt:140-149](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt#L140-L149), URL validation in [DeveloperProfileRoot.kt:81-85](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt#L81-L85).
- `koinInject<HttpClient>(named("test"))` inside composables: [About.kt:98-100](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt#L98-L100), [RepoMarkdown.kt:28](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/repo-pages/presentation/src/commonMain/kotlin/zed/rainxch/repopages/presentation/components/RepoMarkdown.kt#L28), and 4 more.
- Two lifecycle-observer styles: [TweaksRoot.kt:75-84](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt#L75-L84) against [AuthenticationRoot.kt:121](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt#L121).

### Design system

- Spacing tokens exist ([Spacing.kt:7](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/spacing/Spacing.kt#L7)) and are used 0 times in features, against 1,382 raw `dp` literals and 285 `fontSize` overrides.
- Core components bypass `LocalPersonality` through `MaterialTheme`, whose typography is the Material default because `PersonalityTheme` only bridges colors: [AdaptiveListDetail.kt:201](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/adaptive/AdaptiveListDetail.kt#L201), [MarkdownUtils.kt:31](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/markdown/MarkdownUtils.kt#L31). README markdown therefore ignores the personality fonts.
- `isSystemInDarkTheme()` ignores the in-app theme setting: [ExpandableDetails.kt:89](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/markdown/ExpandableDetails.kt#L89), [AppHeader.kt:121](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/AppHeader.kt#L121).
- `rememberMarkdownColors()` and `rememberMarkdownTypography()` never call `remember` ([MarkdownUtils.kt:17](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/markdown/MarkdownUtils.kt#L17)).
- Duplicated components: `FavoriteToggle` x3 ([DeveloperRepoItem.kt:231](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/DeveloperRepoItem.kt#L231), [FavouriteRepositoryItem.kt:198](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/components/FavouriteRepositoryItem.kt#L198), [StarredRepositoryItem.kt:235](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/components/StarredRepositoryItem.kt#L235), with different alphas), platform pickers ([FeedPlatformPicker.kt:47](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/feed/presentation/src/commonMain/kotlin/zed/rainxch/feed/presentation/components/FeedPlatformPicker.kt#L47) against [HomePlatformPicker.kt:49](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HomePlatformPicker.kt#L49)), `SourceChip` x2, search bars x3, sort dropdowns x3, `StatusDot` x2.
- Hardcoded colors: [DetailsRoot.kt:305](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt#L305), [DowngradeWarningColor.kt:8](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/DowngradeWarningColor.kt#L8), [PersonalityTile.kt:41-46](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/appearance/PersonalityTile.kt#L41-L46).

### Data layer

- Feature modules build their own `HttpClient`s that are never closed and skip the proxy: [FeedRepositoryImpl.kt:41](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/feed/data/src/commonMain/kotlin/zed/rainxch/feed/data/repository/FeedRepositoryImpl.kt#L41), [CachedRepositoriesDataSourceImpl.kt:45](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt#L45), [GitHubAuthApi.kt:38](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/network/GitHubAuthApi.kt#L38).
- Proxy setup is written 4 times with different rules ([HttpClientFactory.android.kt:31-33](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt#L31-L33), [HttpClientFactory.jvm.kt:37](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt#L37), [AndroidDownloader.kt:112-113](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt#L112-L113)), and the two downloaders are near-copies (585 and 568 lines, 47 differing lines); only Desktop is tested.
- Three caching strategies across Home and Feed; DTOs and mappers in four different places (`dto/` + `mappers/`, private inside the repository [HomeRepositoryImpl.kt:661-672](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt#L661-L672), inline [StarredRepositoryImpl.kt:139-159](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/StarredRepositoryImpl.kt#L139-L159)).
- 10 classes create private `CoroutineScope`s that are cancelled only in a `close()` nothing calls, or not at all ([BackendApiClient.kt:55](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt#L55), [TweaksRepositoryImpl.kt:46](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt#L46), ...), while an app scope is injected elsewhere ([DefaultDownloadOrchestrator.kt:52](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt#L52)).
- Three migration styles, and Desktop is destructive (bug 2).

### DI

- Production HTTP client qualified `"test"`: [SharedModule.kt:350-354](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt#L350-L354).
- Stateful singleton `object`s outside DI: [ProxyManager.kt:23](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt#L23), [BackendRateLimitTracker.kt:8](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendRateLimitTracker.kt#L8), [AuthDeepLinkBus.kt:9](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthDeepLinkBus.kt#L9), [TweaksDeepLinkBus.kt:9](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/utils/TweaksDeepLinkBus.kt#L9).
- Service location in Android components: [UpdateCheckWorker.kt:35-41](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt#L35-L41), [BootReceiver.kt:20](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/BootReceiver.kt#L20).
- An unqualified app `CoroutineScope` singleton resolved by bare type ([SharedModule.kt:93-95](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt#L93-L95)).

### Naming

- Actions: `OnX` ([DetailsAction.kt:80](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt#L80)) against bare verbs ([AuthenticationAction.kt:6](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt#L6)). Events named like Actions ([HomeEvent.kt:4](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeEvent.kt#L4)).
- `XState` against `XUiState` ([IssuesUiState.kt:8](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/repo-pages/presentation/src/commonMain/kotlin/zed/rainxch/repopages/presentation/issues/IssuesUiState.kt#L8)).
- Favourite / Favorite ([FavouritesAction.kt:9](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesAction.kt#L9) against [ImportStarsAction.kt:10](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/import/ImportStarsAction.kt#L10)).
- GitHub / Github ([GitHubClientProvider.kt:20](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt#L20) against [GithubAssetAuth.kt:3](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GithubAssetAuth.kt#L3)).
- Enum entries in PascalCase ([DiscoveryPlatform.kt:4](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/repository/DiscoveryPlatform.kt#L4)) against SCREAMING_CASE ([InstallSource.kt:3](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/installation/InstallSource.kt#L3)).
- snake_case packages (`data_source`, `use_cases`, `local/data_store`): 13 ktlint `package-name` hits.
- Search domain uses package `zed.rainxch.domain` instead of `zed.rainxch.search.domain` ([SearchRepository.kt:1](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.kt#L1)).
- A repository interface and a model share a name ([StarredRepository.kt:4-7](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/StarredRepository.kt#L4-L7)).
- Repository read names: `getX()` / `observeX()` / `xFlow()` / `getXAsFlow()` for the same idea ([FavouritesRepository.kt:7](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/FavouritesRepository.kt#L7), [AnnouncementsRepository.kt:8](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AnnouncementsRepository.kt#L8), [ExternalImportRepository.kt:10](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt#L10), [InstalledAppsRepository.kt:19](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt#L19)).

### Hygiene

- Dead code:
  - `awaitDeviceToken`, 146 lines ([AuthenticationRepositoryImpl.kt:99](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/repository/AuthenticationRepositoryImpl.kt#L99))
  - `parseGithubUrl` ([AppsViewModel.kt:2331](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L2331))
  - `reportIssue()` ([ReportIssue.kt:31](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReportIssue.kt#L31))
  - the unreachable `RepoPullsScreen` route ([GithubStoreGraph.kt:132](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt#L132))
  - unreferenced components ([TweaksStubScreen.kt:34](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/TweaksStubScreen.kt#L34))
  - empty favourites and starred domain/data modules still wired into the build ([settings.gradle.kts:61-62](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/settings.gradle.kts#L61-L62), [settings.gradle.kts:72-73](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/settings.gradle.kts#L72-L73))
- JVM-only APIs in `commonMain`: [AppsViewModel.kt:74](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L74) (`java.io.File`), [PkceGenerator.kt:3-4](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/crypto/PkceGenerator.kt#L3-L4), [TweaksViewModel.kt:165](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt#L165) (`String.format`).
- Blank lines left where comments were stripped: [AppsViewModel.kt:199](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L199), [TweaksViewModel.kt:756](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt#L756).
- Fully qualified names in bodies: [AppsViewModel.kt:1944](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt#L1944), [TweaksViewModel.kt:358](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt#L358).
- Redundant `@OptIn(ExperimentalTime::class)` when the convention plugin already opts in ([KotlinMultiplatform.kt:21](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/build-logic/convention/src/main/kotlin/zed/rainxch/githubstore/convention/KotlinMultiplatform.kt#L21)).
- 72 em-dashes in core/data sources, mostly log strings.
- Hardcoded English in the data and domain layers: [ForgeKind.kt:15](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/account/ForgeKind.kt#L15), [HostNames.kt:27](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/account/HostNames.kt#L27), [UpdateCheckWorker.kt:160-162](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt#L160-L162).

### Tests

- Test naming is split: snake_case sentences ([UpdateVerdictTest.kt:62](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonTest/kotlin/zed/rainxch/core/domain/utils/UpdateVerdictTest.kt#L62)) and camelCase ([InstallBindingTest.kt:71](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/commonTest/kotlin/zed/rainxch/core/domain/model/installation/InstallBindingTest.kt#L71)).
- The 30-method `InstalledAppsRepository` fake is rewritten in 4 test files ([DefaultDownloadOrchestratorParkTest.kt:250](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/jvmTest/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestratorParkTest.kt#L250), [SyncInstalledAppsUseCaseParkTest.kt:352](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/jvmTest/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCaseParkTest.kt#L352), ...).
- A replay test copies production logic it is meant to check ([AssetMatchingReplayTest.kt:59](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/domain/src/jvmTest/kotlin/zed/rainxch/core/domain/utils/AssetMatchingReplayTest.kt#L59)).

## 8. Docs drift

Agents read these files on every task. Each wrong line here produces wrong code later.

Status: fixed by [#1065](https://github.com/komi-store/komi-store/pull/1065) and [#1076](https://github.com/komi-store/komi-store/pull/1076); the table stays as the record of what had drifted.

| Claim | Reality |
|---|---|
| Root CLAUDE.md: version 1.8.3 (code 18) | the catalog says 1.9.3 / 22 ([libs.versions.toml:59-60](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/gradle/libs.versions.toml#L59-L60)) |
| Feature list has 11 features, typo "homeP" | 13 features; `feed` and `repo-pages` are missing ([settings.gradle.kts:69-71](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/settings.gradle.kts#L69-L71), [settings.gradle.kts:58-60](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/settings.gradle.kts#L58-L60)) |
| favourites and starred are presentation-only | their domain/data modules exist and are empty; profile and tweaks are presentation-only too and are not listed |
| Route list includes `HomeScreen`, `OnboardingScreen` | neither exists; 13 real routes are missing ([GithubStoreGraph.kt:8](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt#L8)) |
| Core repositories include `AuthenticationState`, `ThemesRepository`, `TelemetryRepository`, `SearchHistoryRepository` | the first is now `UserSessionRepository`, the next two don't exist, the last is in `feature/search/domain` ([SearchHistoryRepository.kt:5](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchHistoryRepository.kt#L5)) |
| `HostTokenInterceptor` injects per-host PATs | it was deleted in #666 (bug 1) |
| KSafe wraps every persisted credential through `safeGet` / `safePut` | raw calls exist, including the token store itself ([HostTokenRepositoryImpl.kt:138](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/HostTokenRepositoryImpl.kt#L138)) |
| Tech: Landscapist 2.9.5 | images use Coil 3 ([build.gradle.kts:15-17](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/build.gradle.kts#L15-L17)) |
| core/presentation is a "Material 3 theme" | it is the custom `Personality` system; Material only receives colors ([PersonalityThemeProvider.kt:39-47](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/personality/utils/PersonalityThemeProvider.kt#L39-L47)) |
| Every ViewModel follows the `_state` + `_events` + `onAction` template | 18 of 33 do, counting `stateIn` as allowed; the rest vary (D1) |
| Packages are `zed.rainxch.{module}.{layer}` | search domain breaks it ([SearchRepository.kt:1](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.kt#L1)) |
| feature/home: 7-day cache TTL, dedupe by `fullName` | 1 hour ([CachedRepositoriesDataSourceImpl.kt:480](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt#L480)), dedupe by `id` ([HomeViewModel.kt:264](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt#L264)) |
| feature/details: injects `TelemetryRepository`, `ProfileRepository`; DownloadStage ends in DONE | real deps differ ([DetailsViewModel.kt:145](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt#L145)); there is no DONE ([DownloadStage.kt:3-9](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/DownloadStage.kt#L3-L9)) |
| feature/tweaks: sections Account, Language, Network; Android restart works | none of those sections exist; the Android restart is empty ([RestartApp.android.kt:3-5](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/tweaks/presentation/src/androidMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.android.kt#L3-L5)) |
| feature/search: queries are debounced | no debounce exists |
| feature/auth: no retry loops on top of `HttpRequestRetry` | `withRetry(3)` wraps a client that already retries ([GitHubAuthApi.kt:67](https://github.com/komi-store/komi-store/blob/cfa6bd30e6b35cbbc369d78a4599182a9a66beac/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/network/GitHubAuthApi.kt#L67)) |
| CONTRIBUTING: CI lints every PR; MVI; wire ViewModels in `initKoin.kt` | no PR CI; root CLAUDE.md says MVVM; ViewModels live in `ViewModelsModule.kt` |

Recommendation: rewrite root CLAUDE.md to a short factual index, delete the stale parts of the feature files rather than patching every line, add the missing `feed` and `repo-pages` files, and point everything at one style guide.

## 9. Where each rule lives

The higher the rung, the less it depends on anyone remembering.

| Rule | Rung | Status |
|---|---|---|
| Formatting, imports, naming basics | Spotless (ktlint 1.8.0) | blocking on files changed against `main` |
| Compose API basics (modifier, parameter order, naming) | Spotless with the Compose rules | same |
| Complexity, exceptions, smells | detekt | per-module baselines, new findings fail |
| Android Lint | `lintDebug` | per-module baselines |
| Compose stability | stability analyzer | committed `stability/*.stability` files |
| Common domain code imports no Ktor, Room, Compose, lifecycle, Koin or Android | Konsist | passing |
| Presentation never imports data | Konsist | passing; `MirrorPickerViewModel` is allowlisted for the `core.data` import that came with #1015 |
| Packages under `zed.rainxch.<owner>.<layer>` | Konsist | passing, 7 search files allowlisted |
| ViewModels named `*ViewModel` and only in presentation or composeApp | Konsist | passing |
| Top-level `*Action` / `*Event` types are sealed | Konsist | passing |
| File size ratchet, 800 lines (D8) | Konsist | passing, 15 files allowlisted |
| `XViewModel` shape (D1) | Konsist | follow-up, with an allowlist of today's off-pattern ViewModels |
| No `runCatching` around suspend calls (A1), no `e.message` in presentation (A3), no `MaterialTheme` or `Color(0x...)` in features, no `koinInject` in composables | Konsist | follow-up, with allowlists |
| Previews (A5), CLAUDE.md changes with the code (A9) | review | |
| All of the above, plus compile and `jvmTest` with coverage, on every PR | GitHub Actions `pr-checks.yml` | in place |
| Everything else | `docs/code-style.md` | in place |

## 10. Follow-up work

In order:

1. **Done in [#1076](https://github.com/komi-store/komi-store/pull/1076). Contributor docs**: a short public `AGENTS.md`, a slimmer public `CLAUDE.md`, a refreshed `CONTRIBUTING.md`, a PR template, and the feature CLAUDE.md files corrected against the code (already prepared).
2. **Open fix PRs land first, after [#1056](https://github.com/komi-store/komi-store/pull/1056) and this change**: [#1063](https://github.com/komi-store/komi-store/pull/1063) (bug 1), [#1062](https://github.com/komi-store/komi-store/pull/1062) (bug 2), the rate-limit fix (bugs 3 and 4), and other open work, so none of them has to rebase across a mass format.
3. **Done in [#1080](https://github.com/komi-store/komi-store/pull/1080). The one-time format (D4)** as its own PR at a quiet moment: one `spotlessApply` over every file with the ratchet off for that run, the Compose findings fixed or suppressed, and the `feature:details:presentation` warnings opt-out dropped with its five warnings fixed. Then the squash SHA goes into `.git-blame-ignore-revs`.
4. **Code changes from the decisions**, one PR each:
   - power-assert (A6), no wildcard imports (A7) and no stability annotations on screen state (D9), right after the format
   - error handling (D2, A1, A3) with Konsist rules for `runCatching` around suspend calls and `e.message` in presentation
   - core UI: a shared load / error / empty renderer (A2), localized content descriptions (A4), previews (A5)
   - ViewModels and navigation: the D1 shape as a Konsist rule, navigation lambdas (D3), typed route arguments (A8)
   - the test stack and the first ViewModel tests (D10)
   - logging through the injected `KomiStoreLogger` (D7)
5. **Done. Issues** for bugs 5 to 9: [#1071](https://github.com/komi-store/komi-store/issues/1071) to [#1075](https://github.com/komi-store/komi-store/issues/1075).

---

--- METRICS ---
- Files Extracted: 11
- Estimated Token Budget: ~36249 tokens
- Canonical Reference: https://codewiki.google/github.com/kurikomi-labs/komi-store
