Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .claude/agents/compose-conventions.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
---
name: compose-conventions
description: Use this agent to audit the two Compose conventions nothing in this build enforces — every composable that emits UI takes a modifier parameter and applies it to its root, and every composable that emits UI has a @Preview. Typical triggers include a pre-PR sweep after adding or reshaping UI, and checking one file or directory you have just written. Do not use it to review Compose code for correctness or design; it checks two mechanical rules and nothing else.
description: Use this agent to audit the Compose preview convention, which nothing in this build enforces — every composable that emits UI has a @Preview covering its easy-to-break states — and to pre-check the modifier convention before detekt runs. Typical triggers include a pre-PR sweep after adding or reshaping UI, and checking one file or directory you have just written. Do not use it to review Compose code for correctness or design; it checks two mechanical rules and nothing else.
model: haiku
effort: medium
color: magenta
tools: Read, Grep, Glob
---

You audit Compose source in the Countries repository against two conventions. `detekt/detekt.yml`
already enables `ModifierMissing` and `ModifierNotUsedAtRoot`, but detekt is currently wired up
only for `build-logic`, so **nothing checks these in the modules** — you are the check. You are
read-only by construction: you report violations and stop.
You audit Compose source in the Countries repository against two conventions. **Rule 2 (previews)
has no other check — you are it.** Rule 1 (modifiers) is also enforced by detekt's
`ModifierMissing` and `ModifierNotUsedAtRoot` (`config/detekt/detekt.yml`, run by `./gradlew
build`), so for Rule 1 you are a faster pre-check than a full build, not the only line of defence.
You are read-only by construction: you report violations and stop.

Composables live in `ui/src/commonMain/kotlin/io/github/solcott/countries/ui/`. A few also live in
`desktop/src/jvmMain` and `web/src/commonMain`. Unless the caller narrows the scope, audit `ui`.
`desktop/src/main` and `web/src/commonMain`. Unless the caller narrows the scope, audit `ui`.

## Rule 1 — modifier parameter

Expand Down
41 changes: 41 additions & 0 deletions .claude/rules/app-icons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
paths:
- "desktop/icons/**"
- "iosApp/**/AppIcon.appiconset/**"
---

# App icons on Apple

The PNGs in `iosApp/Countries/Assets.xcassets/AppIcon.appiconset` are **derived from
`desktop/icons/icon.icns`** (the only file with a 1024×1024 representation) and committed as plain
images. There is no generator; redo them by hand when the artwork changes.

| | macOS | iOS |
| --- | --- | --- |
| Shape | rounded rect inset in a transparent margin, as drawn | **full bleed**, square |
| Alpha | required | **must not have any** |
| Sizes | ten, 16pt–512pt @1x/2x | one 1024×1024 universal |

**macOS** — the source art unchanged. `icon_16x16.png` … `icon_512x512@2x.png` map one-for-one onto
the `mac-*` filenames:

```
iconutil -c iconset desktop/icons/icon.icns -o /tmp/icon.iconset
```

**iOS** — iOS applies its own superellipse mask, and App Store validation rejects any alpha. Build
from `icon_512x512@2x.png` in four steps:

1. **Crop to `(61, 61, 963, 963)`** — the alpha channel's bounding box. Re-measure if the art is
redrawn.
2. **Scale the 902px crop to 1024×1024** — keeps the globe at 71.2% of the visible icon, as on macOS.
3. **Composite over an opaque vertical gradient, `#785F98` → `#584077`** (sampled at the rect's top
and bottom edges) to fill the transparent corners seamlessly.
4. **Flatten to RGB.**

Check the result is `RGB`, not `RGBA`, and its four corner pixels equal the gradient endpoints.
`actool` adding an opaque alpha to the compiled output is expected.

**A missing image fails silently.** An `.appiconset` whose `Contents.json` lists sizes but no
`filename` keys builds clean and produces an app with no icon. Verify in the built bundle, never the
build log: `Countries.app/AppIcon60x60@2x.png` on iOS, `Contents/Resources/AppIcon.icns` on macOS.
72 changes: 72 additions & 0 deletions .claude/rules/build-scripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
paths:
- "gradle/libs.versions.toml"
- "**/build.gradle.kts"
- "settings.gradle.kts"
- "gradle.properties"
- "build-logic/**"
- "kotlin-js-store/**"
---

# Build scripts and the version catalog

**`libs.versions.toml` carries the reasoning for every non-obvious pin inline. Update those comments
when bumping; never delete them.** For a Compose, BOM, Kotlin or material3 bump, follow the
`dependency-bump` skill — it has the verification procedure.

## Fails silently

1. **Never drop `composeMultiplatform` below 1.12.** Below it, every emoji and non-Latin script
renders as tofu in the browser, and everything still builds. See the `compose-fonts` skill.
2. **material3 is on its own line — `composeMaterial3`, not `composeMultiplatform`**, and so is
`composeMaterial3Adaptive`. CMP's `compose.material3` accessor would drag AndroidX material3
backwards.
3. **material3 is deliberately outside the BOM.** A direct version beats the BOM's lower 1.4.0.
Re-check after every BOM bump: if the BOM ever pins it *higher*, the BOM silently wins.
4. **The AndroidX Compose BOM is for Android configurations only** (`:app`, and the
`androidMain.dependencies` of `:ui` and `:presenter`). **Never in `commonMain`** — it would drag
jvm/native/web onto the AndroidX line.
5. **`circuit` and `metro` bump together.** Metro 1.4.2 is the floor for `@CircuitSerializable`
registrations. An older one compiles and contributes nothing. Run `:shared-compose:allTests`.
6. **A `dataresult`/`uistate` bump can break the iOS build**, since both are exported to Swift in
full. Run `:apple:macosArm64Test`, then the iOS build.

## Build requirements for Compose modules

- **Apply `alias(libs.plugins.compose.multiplatform)`**, even though every dependency is a catalog
coordinate, because it configures skiko's web packaging. Keep `plugin.compose` alongside it.
- **`org.jetbrains.compose.experimental.macos.enabled=true`** stays in `gradle.properties`.
- **`android { androidResources { enable = true } }`** inside `kotlin { }` wherever there are
`composeResources`. Without it the app hits `MissingResourceException` at runtime.
- **`binaries.executable()` on `js` and `wasmJs`** for any Compose *library* with browser test
tasks. CMP 1.12's `checkComposeUiTestConfigurationFor{Js,WasmJs}` hard-fails otherwise, with no
opt-out.
- A `platform(...)` in a KMP source-set `dependencies { }` block must be
`project.dependencies.platform(...)`.

## General

- **Library modules apply `id("kmp-library")`, never `com.android.library` or
`org.jetbrains.kotlin.plugin.parcelize`.** Use `alias(libs.plugins.kmp.parcelize)`. `:web`,
`:desktop` and `:apple` apply no `kmp-library`.
- **Android modules must not apply `org.jetbrains.kotlin.android`** (AGP 9 has built-in Kotlin).
- Kotlin-family plugins (`plugin.compose`, `plugin.parcelize`, `plugin.serialization`) and
`org.jetbrains.compose.hot-reload` are applied **by id with no version**. The root buildscript
classpath forces the KGP and Compose-compiler versions Metro needs, so check that force when
bumping `kotlin`. **`kotlin` is pinned to 2.4.20 for Swift export.**
- Modules target **Java 17** via `Versions` in `build-logic`. A module script can
`import io.github.solcott.countries.build.Versions` — `Versions.class` rides in the same
`build-logic.jar` as the plugin descriptors, so applying any convention (every module applies at
least `formatting`) puts it on the script's classpath. A one-off module imports it rather than
earning a new convention, which is why `kmp-library` and `app` are the only two.
`build-logic`'s own `jvmToolchain(25)` is a different fact: the JVM the convention plugins
compile against, matching the daemon.
- `settings.gradle.kts` applies the foojay resolver so Gradle can auto-provision missing JDKs.
- `settings.gradle.kts` stays on `RepositoriesMode.PREFER_SETTINGS`. The Kotlin plugin registers
project repos for Node/Yarn/Binaryen, which `FAIL_ON_PROJECT_REPOS` rejects.
- **Two npm lockfiles**, `kotlin-js-store/yarn.lock` (js) and `kotlin-js-store/wasm/yarn.lock`
(wasmJs). Regenerate both with `kotlinUpgradeYarnLock` **and** `kotlinWasmUpgradeYarnLock`. Never
hand-edit them. "Lock file was changed" names only one of the two tasks.
- **`ktfmtCheck` at the root does not cover `build-logic`** (an included build). Run
`./gradlew -p build-logic ktfmtCheck` too.
- detekt's live config is `config/detekt/detekt.yml`, applied by the `detekt` convention.
28 changes: 28 additions & 0 deletions .claude/rules/compose-resources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
paths:
- "**/composeResources/**"
---

# Compose Multiplatform resources

Strings and drawables live in `src/commonMain/composeResources/` and are reached through the
generated `Res`, never AGP's `R`:

```kotlin
import io.github.solcott.countries.ui.resources.Res
import io.github.solcott.countries.ui.resources.capital
import org.jetbrains.compose.resources.stringResource

stringResource(Res.string.capital, country.capital)
painterResource(Res.drawable.globe_24px)
```

- `strings.xml` keeps the ordinary Android format, `%1$s` placeholders included.
- **A vector drawable must contain no `?attr/…` and no `@android:…`.** CMP's parser resolves
neither, and **both fail at runtime, not at build time.** Use literal colours (`#FFFFFFFF`) and
let `Icon` tint from `LocalContentColor`. The existing drawables are the right shape — copy one.
- **A module with `composeResources` needs `android { androidResources { enable = true } }`** inside
`kotlin { }` (see `ui/build.gradle.kts`). Without it the APK has no `assets/composeResources/` and
the app dies on first use with `MissingResourceException` — nothing warns at build time.

Neither failure shows up in a Gradle task. Look at the running app.
61 changes: 61 additions & 0 deletions .claude/rules/compose-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
paths:
- "ui/**/*.kt"
---

# `:ui` — Compose UI

- **`:ui` has no `android.*` imports at all.** There is no `AndroidView` escape hatch on five of six
platforms.
- **Exactly one platform seam: `LocalFlagFontFamily`**, null everywhere but desktop. Do not add a
second — see the `compose-fonts` skill.
- **How a platform looks is an `AppSkin` parameter, not a seam.** `MaterialSkin`, `DesktopSkin`,
`WebSkin` live in `ui/…/theme/`; composables read tokens from `LocalAppSkin`. `contentMaxWidth`
and `contentPanel` are what make `:web` read as a page. **`WebSkin`'s page colour is duplicated in
`web/…/styles.css`** — change both.
- `AppSkin` detail: a skin has no `expect`/`actual`, lives in no platform source set, and can be
rendered from Android Studio — which is why the UI is not forked per platform. `AppTheme` also
feeds `minInteractiveSize` into `LocalMinimumInteractiveComponentSize`, the one value that takes
the whole Material control set from a 48dp touch target to pointer density.
- Screen-agnostic wiring (theme, backstack, `CircuitCompositionLocals`, `NavigableCircuitContent`)
belongs in `CountriesApp`, not in a screen or an entry point.
- The Ui is a pure function of Circuit state and emits events — no business logic, no data access.
- In a multi-pane layout Circuit stops collecting for the non-current record; the
`ProvideRecordLifecycle(isActive = true)` in `ListDetailNavDecoration.kt` is what keeps the list
pane live. Keep it.

## Modifiers

**Every composable that emits UI takes `modifier: Modifier = Modifier` as its first optional
parameter and applies it to its root element**, private helpers included. detekt's `ModifierMissing`
and `ModifierNotUsedAtRoot` enforce this in `./gradlew build`. The exceptions emit nothing:
`AppTheme` (a wrapper) and `DataError.toUserMessage()` (returns a `String`).

## Previews — required for every composable that emits UI

Nothing enforces this; the `compose-conventions` subagent audits it.

- **Import `androidx.compose.ui.tooling.preview.Preview`** (from
`org.jetbrains.compose.ui:ui-tooling-preview` — the AndroidX names, in `commonMain`). **Not**
`org.jetbrains.compose.ui.tooling.preview.Preview`, the older parameterless annotation.
- Project multipreviews in `PreviewSupport.kt`:

| Annotation | Use on | Renders |
| --- | --- | --- |
| `@AppScreenPreviews` | whole screens | `@PreviewScreenSizes` plus a compact browser window |
| `@ComponentWidthPreviews` | a strip inside a screen | 360dp, 700dp, 1280dp |

- **Budget:** the happy path gets the full size sweep; loading, error, empty and refreshing get
`@PreviewLightDark` at phone size. Preview the states that break, not just the happy path.
- **Reuse the fixtures** in `PreviewSupport.kt` — `PreviewSurface`, `loadedState`, `loadingState`,
`refreshingState`, `failedState`, and the sample `Country`/`Continent`/`CountryDetail`. The sample
list deliberately includes a wrapping name and a null capital.
- `PreviewSurface` is the one exception: it is the harness.
- **The renderer is `androidRuntimeClasspath`, not `implementation`** in `ui/build.gradle.kts` —
the IDE needs `ComposeViewAdapter` there, and this keeps it out of `:app`. If previews render
nothing, check that first.

## Verify

`./gradlew :ui:assemble assembleDebug` — `:ui` has no tests. To see it, use the desktop app under
hot reload (the `desktop-app` skill).
56 changes: 56 additions & 0 deletions .claude/rules/desktop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
paths:
- "desktop/**"
---

# `:desktop` — the Windows/Linux/macOS app

- **A plain `kotlin("jvm")` module, not multiplatform**, and it does not apply `kmp-library`. It
declares `kotlin("test")` and the JVM toolchain itself (importing `Versions` from `build-logic`).
- **`compose.desktop.currentOs` is the one plugin-accessor dependency** — skiko's jar is classified
by OS and arch. So everything built here, including `packageUberJarForCurrentOS`, runs on the
**build host's OS only**; jpackage cannot cross-build.
- **`nativeDistributions { modules(...) }` is load-bearing and fails invisibly.** jpackage jlinks a
trimmed JDK; the default set lacks `java.sql`/`jdk.unsupported` (sqlite-jdbc under the Apollo
cache) and `java.naming`/`jdk.crypto.ec` (OkHttp TLS). `run` uses the full JDK, so a missing
module shows up only in an *installed* build, as a crash on the first query. **Test packaging
changes with `packageDistributionForCurrentOS`, never `run`.**
- **The window is `androidx.compose.ui.window.v2`** (`@OptIn(ExperimentalComposeUiApi::class)`),
which is what makes `minSize` (480×600) a `Window` parameter. If the API moves before
stabilisation, only `Main.kt`'s imports change.
- **Keyboard back is `isBackShortcut()` in `BackShortcut.kt`**, pure and tested. The backstack is
hoisted so `onKeyEvent` can reach it, which is why `Main.kt` passes
`circuitSaver = graph.circuitSaver`. `onRootPop` stays a no-op — Esc on the root must not quit.
- **`Window(icon = …)` is a title-bar icon, not a dock icon.** macOS takes the dock icon from the
bundle or `java.awt.Taskbar`; `applyTaskbarIcon()` in `AppIcon.kt` sets it before the first
window (no-op elsewhere). No task can see a dock icon: check with `:desktop:run`, and check the
bundle icon separately with `packageDistributionForCurrentOS` — either can regress alone.
- `desktop/icons/` is the app icon source for **every** platform — see `app-icons.md`.
`build.gradle.kts` adds it as a resource root and excludes `*.icns`/`*.ico` from the jar.
- **The hot-reload plugin is applied by id with no version and has no catalog alias** — CMP already
puts it on the classpath, and naming a version fails the build. Nothing it adds ships.
- The bundled flag font (`src/main/resources/font/`) — see the `compose-fonts` skill before
touching it: it must be the CBDT build and must never be handed to macOS.

The SwiftUI app is also a process named `Countries`; when driving the window by process name, target
a pid or use the hot-reload MCP tools. Running under hot reload: the `desktop-app` skill.

## Commands

```
# Desktop app
./gradlew :desktop:run
./gradlew :desktop:packageUberJarForCurrentOS # → desktop/build/compose/jars
./gradlew :desktop:packageDistributionForCurrentOS # → desktop/build/compose/binaries

# Desktop app under Compose Hot Reload. Edits anywhere in `:ui` land in the running window in
# about a second, which is the fastest way to see a UI change on any platform here. The MCP server
# is what lets an agent look at that window — screenshots, the semantics tree, clicks and typing.
# It is wired up in `.mcp.json`, so an agent starts and stops it itself.
./gradlew :desktop:hotRun --autoReload
./gradlew :desktop:hotMcpServer
```

## Verify

`./gradlew :desktop:test`; for packaging, `./gradlew :desktop:packageDistributionForCurrentOS`.
34 changes: 34 additions & 0 deletions .claude/rules/kmp-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
paths:
- "**/src/*Test/**"
- "**/src/test/**"
---

# Tests in KMP modules

`src/commonTest` runs on **six** runners via `allTests`: `jvmTest`, `testAndroidHostTest`,
`jsBrowserTest`, `wasmJsBrowserTest`, `macosArm64Test`, `iosSimulatorArm64Test`. `kotlin("test")`
is wired in by `kmp-library`. Add `libs.kotlinx.coroutines.test` per module for `runTest`.

- **camelCase test names**, not backticked names with spaces. Only camelCase works on the JS and
native runners.
- **No JUnit** in a KMP module. Use `kotlin.test` assertions and Turbine.
- **`SnapshotStateList.equals` is identity-based on native and JS** and structural on JVM, so call
`.toList()` before `assertEquals`. Expect other JVM-only accidents like this.
- **`testAndroidHostTest` links the android.jar stubs.** A `SavedState`/`Bundle` saves nothing
there and restores as `null`, failing on one runner out of six. Put such tests in `jvmTest`.
- **The web targets are `browser()` only. Never add `nodejs()`.** Molecule's frame clock never
advances under Node. The browser runners need Chrome, and the Apple runners need Xcode and boot a
simulator.
- **The first test in a Compose module needs `js { binaries.executable() }`, the same for `wasmJs`,
and the Compose Multiplatform plugin.** Without them the task reports *"did not discover any
tests"* instead of naming the cause. A karma `browserNoActivityTimeout` that is too short on CI
produces the same message; see `shared-compose/karma.config.d/`.
- **The first native test binary that links the whole graph needs `linkerOpts("-lsqlite3")`.**
- Adding tests can change `kotlin-js-store/yarn.lock`. Regenerate it with `kotlinUpgradeYarnLock`,
and also with `kotlinWasmUpgradeYarnLock` if the wasm lockfile moved too.
- An injected Kermit `Logger` can be asserted on with `kermit-test`'s `TestLogWriter`, which needs
`@OptIn(ExperimentalKermitApi::class)`. `MappersTest` is the example.

Tests exist in `apple`, `repository`, `presenter`, `shared-compose`, `web` and `desktop`. A green
test task elsewhere ran nothing.
Loading
Loading