diff --git a/.claude/agents/compose-conventions.md b/.claude/agents/compose-conventions.md index 099929a..914009e 100644 --- a/.claude/agents/compose-conventions.md +++ b/.claude/agents/compose-conventions.md @@ -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 diff --git a/.claude/rules/app-icons.md b/.claude/rules/app-icons.md new file mode 100644 index 0000000..92fb450 --- /dev/null +++ b/.claude/rules/app-icons.md @@ -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. diff --git a/.claude/rules/build-scripts.md b/.claude/rules/build-scripts.md new file mode 100644 index 0000000..834fe86 --- /dev/null +++ b/.claude/rules/build-scripts.md @@ -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. diff --git a/.claude/rules/compose-resources.md b/.claude/rules/compose-resources.md new file mode 100644 index 0000000..593ca7b --- /dev/null +++ b/.claude/rules/compose-resources.md @@ -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. diff --git a/.claude/rules/compose-ui.md b/.claude/rules/compose-ui.md new file mode 100644 index 0000000..a597792 --- /dev/null +++ b/.claude/rules/compose-ui.md @@ -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). diff --git a/.claude/rules/desktop.md b/.claude/rules/desktop.md new file mode 100644 index 0000000..08f4c98 --- /dev/null +++ b/.claude/rules/desktop.md @@ -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`. diff --git a/.claude/rules/kmp-tests.md b/.claude/rules/kmp-tests.md new file mode 100644 index 0000000..8e353c8 --- /dev/null +++ b/.claude/rules/kmp-tests.md @@ -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. diff --git a/.claude/rules/metro-graph.md b/.claude/rules/metro-graph.md new file mode 100644 index 0000000..dd9b935 --- /dev/null +++ b/.claude/rules/metro-graph.md @@ -0,0 +1,38 @@ +--- +paths: + - "shared/**" + - "shared-compose/**" +--- + +# The Metro graphs — `ComposeGraph` and `CoreGraph` + +Two rules that produce confusing errors rather than obvious ones: + +1. **Contributions are resolved on the compile classpath of the module that declares + `@DependencyGraph`.** Metro locates `metro.hints` during graph supertype generation, so a module + added downstream (in an app module) is too late and its providers simply do not appear. That is + why `ComposeGraph` lives in `shared-compose`. + *Symptom:* `[Metro/MissingBinding] No binding found for …`. +2. **Contributing modules must be `api`, not `implementation`, on the graph module.** Contributed + interfaces become supertypes of the generated graph. + *Symptom:* `Cannot access '…NetworkProviders' which is a supertype of 'ComposeGraph'`. + +- `ComposeGraph` exposes `Circuit` and is shared by every Compose app; none declares its own graph. + `CoreGraph` exposes repositories and no Compose types — it is what `:apple` uses. +- `graph.circuitSaver` exists for the two call sites that hoist a backstack (`:web`, `:desktop`); + everything else gets it from `LocalCircuitSaver`. +- The root `Logger` is provided here, by `LoggingProviders`, and injected everywhere else. + +## Tests here + +- `ComposeGraphSaverTest` (commonTest) must list every `Screen`. `ComposeGraphSaverRoundTripTest` + sits in `jvmTest` because `testAndroidHostTest`'s stub `Bundle` saves nothing. +- **The graph test binary needs `linkerOpts("-lsqlite3")`.** A klib records no linker options, so it + otherwise fails at link with undefined `_sqlite3_*` symbols. +- **`karma.config.d/` raises `browserNoActivityTimeout`.** The ~15 MB bundle otherwise disconnects on + CI and reports *"did not discover any tests"*. + +## Verify + +`./gradlew assembleDebug :shared-compose:allTests :desktop:packageUberJarForCurrentOS`. Graph errors +surface at the module declaring `@DependencyGraph`, so build a consumer. diff --git a/.claude/rules/network.md b/.claude/rules/network.md new file mode 100644 index 0000000..a39924c --- /dev/null +++ b/.claude/rules/network.md @@ -0,0 +1,75 @@ +--- +paths: + - "network/**" +--- + +# `:network` — Apollo, the normalized cache, the SQL.js worker + +The endpoint is the public API at `https://countries.trevorblades.com/`. + +## Apollo + +- The Apollo Gradle plugin detects KMP by itself: operations in `src/commonMain/graphql/`, generated + code attached to `commonMain`, no `srcDir` or output wiring. It also adds `-lsqlite3` to native + binaries once it sees `normalized-cache-sqlite`. +- **Generated code is build output.** Never hand-edit it, never commit it, never import the + generated package outside `:network`. `:network` maps generated types to `model` types and returns + only the latter. To change what is fetched, change the `.graphql` files. +- **Per-platform client config goes through `ApolloClient.Builder.platformConfiguration()`**, an + `expect` extension in `network/src/commonMain`. Add HTTP engines, interceptors, etc. there — do not + fork the provider. Endpoint, in-memory tier and the Metro provider stay in `commonMain`. +- Caching is Apollo's normalized cache, here. Do not add a second layer anywhere above it. +- `NetworkProviders` is `@ContributesTo(AppScope::class)` and must stay `api` on the graph module. + +## Where the cache lives + +`SqlNormalizedCacheFactory(name)` is an `expect` in Apollo; storage differs per target: + +| Target | Storage | +| --- | --- | +| Android | `cacheDir`, via an `androidx.startup` initializer in the AAR | +| JVM | `~/.apollo` | +| Apple | Application Support | +| js / wasmJs | SQLDelight's SQL.js web-worker driver — **the name is ignored** | + +The web driver's two npm dependencies (on `jsMain` and `wasmJsMain`) are **pinned to the SQLDelight +version `normalized-cache-sqlite` depends on (currently 2.1.0), not the latest.** Renovate cannot see +that transitive pin, so it has SQLDelight disabled — bump it by hand alongside the cache. + +**`sql.js` is declared twice and both must match:** `npm("sql.js", …)` in `network/build.gradle.kts` +(the copied `sql-wasm.wasm`) and the worker's `package.json` (the JS glue). A mismatch makes +`initSqlJs()` hang forever — a silent spinner. Renovate sees only one of them; bump both by hand. + +A browser *application* also needs the `webpack.config.d/` copy of `sql.js`'s `.wasm` (`web/webpack.config.d/sqljs.js`) +— without it the build is clean and the worker 404s at runtime. A library module does not. + +## The SQL.js worker — `network/npm/countries-sqljs-idb-worker/` + +**`createDefaultWebWorkerDriver()` must not come back.** The reference worker does +`new SQL.Database()` and never persists it. Ours loads from IndexedDB at startup and writes +`db.export()` back, debounced, after each transaction. `NetworkProviders.{js,wasmJs}.kt` build the +`WebWorkerDriver` around it by hand. + +- **It is a local npm package, not a loose `.js` file.** `new Worker(new URL(…))` must resolve at + bundle time; a bare specifier out of `node_modules` is the only shape that works from a library. + Both `jsMain` and `wasmJsMain` declare it. +- **The `exec` response must stay `res[0] ?? { values: [] }`.** `db.exec` returns `[]` for an empty + `SELECT`; anything richer makes a cache miss look like a row to SQLDelight's cursor. +- **The database name travels as the worker's own name** (`new Worker(url, { name })`) — the + protocol has no field for it. It keys the IndexedDB snapshot. +- **The `Worker` must not move up into `webMain`.** SQLDelight's `expect class Worker` is a + typealias to `org.w3c.dom.Worker`, which only expands in a *platform* compilation; + `compileWebMainKotlinMetadata` sees an opaque expect class and fails. The seam is + `persistentSqlJsDriver(): SqlDriver` for that reason. **That failure blocks `assemble` for + `:network` and everything above it, and neither web target's own compile task reproduces it.** + +Persisting the cache is not what makes the web app work offline — the service worker is (see +`web.md`). + +## Verify + +- `./gradlew :network:assemble` — not `:network:compileKotlinJs`; only `assemble` catches the + `webMain` metadata trap. +- `./gradlew :repository:allTests` — the mapping tests live there; `:network` has none. +- Worker changed: `./gradlew :web:wasmJsBrowserDevelopmentRun` and confirm the IndexedDB snapshot + survives a reload. diff --git a/.claude/rules/screens.md b/.claude/rules/screens.md new file mode 100644 index 0000000..06e883c --- /dev/null +++ b/.claude/rules/screens.md @@ -0,0 +1,36 @@ +--- +paths: + - "presenter/**" +--- + +# `:presenter` — Screens, presenters, state + +- **A `Screen` carries `@CircuitSerializable(AppScope::class)`, not `@Parcelize`.** It is a + `@MetaSerializable`, so do not also write `@Serializable`. Every property must be serializable — + keep screens to the ids a presenter needs to re-fetch (`CountryDetailScreen(val code: String)`). + **Forgetting it is not a build failure**: the throw lands the first time the screen is saved + (rotation, process death, hot reload). **Add every new screen to `ComposeGraphSaverTest`** in + `:shared-compose`. +- **`@Parcelize` from `io.github.solcott.kmp.parcelize` is still right for state** held in + `rememberSaveable` (e.g. `Continent`). Never `org.jetbrains.kotlin.plugin.parcelize`. +- **`@Redacted` on `eventSink`.** +- **Collect repository flows with `produceRetainedContentState`** (`libs.uistateCircuit`). Do not + hand-roll a `produceRetainedState` fold — the helper's `settled()` safety net is what stops a + cancelled collection reporting an abandoned request as finished. `distinctUntilChanged()` stays + with the caller. For parameters that change on screen (search, filter), use + `params.produceRetainedContentState(…)` with the debounce on `params` — `CountryListPresenter` is + the example. +- **There is no factory to register.** `@CircuitInject` + `metro.enableCircuitCodegen=true` generate + it. If a screen does not resolve, suspect the annotation or the module's place on the graph + classpath. +- **Presenters own state** — business logic and data access live here, never in the Ui. `presenter` + must never depend on `ui`. +- View state types come from `libs.uistate`, read outcomes from `libs.dataresult`, domain nouns from + `:model`. `when` over `Outcome` must handle `Loading`, though this project never emits it. +- A screen-specific derived property is an extension beside the state + (`ContentState.isNotFound`). + +## Verify + +`./gradlew :presenter:allTests :shared-compose:allTests`. The second one is the only check that a +`@CircuitSerializable` registration actually reached the graph. diff --git a/.claude/rules/swift-export.md b/.claude/rules/swift-export.md new file mode 100644 index 0000000..772d8d3 --- /dev/null +++ b/.claude/rules/swift-export.md @@ -0,0 +1,96 @@ +--- +paths: + - "apple/**" + - "iosApp/**" + - "model/**" +--- + +# Swift export — what fails with no warning + +`:apple` exports `:model` and the `dataresult`/`uistate` libraries to Swift **in full**. These rules +apply to anything reachable from them, which is why this file also loads for `model/`. Nothing in +the Kotlin build warns about any of it; the first signal is the iOS build, or a crash. + +- **No Compose type in the exported API.** Compose's `Saver.save` (an extension-receiver method) + generates a malformed thunk, so any Compose type is fatal. That is why `AppleUiState.kt` exists: + Swift sees `CountryListUiState`/`CountryDetailUiState`, never `CountryListScreen.State` (it holds + a `TextFieldState`). Keep `eventSink` `internal` — exporting `Event` risks dragging its sibling + `State` along. +- **Sealed types that cross to Swift are `sealed class`, not `sealed interface`** (re-verified on + Kotlin 2.4.20-RC): + + | | sealed interface | sealed class | + | --- | --- | --- | + | Read via `sealedType()` | works | works | + | **Generic** (`Outcome`) | **generated Swift does not compile** | works | + | Name or construct a member from another module | **unreachable** (typealias defaults to `internal`) | works | + + Screen `Event`s stay interfaces — they never cross to Swift. +- **`PresenterHolder` is deliberately not generic** — a type parameter would be erased to its bound. + `state` is declared concretely on each subclass. Holders call `launchMolecule` directly, **not** + wrapped in `presenterOf { }` (a `@ComposableTarget` mismatch), and expose `cancel()`. +- **A Kotlin class must not share the module's name.** `CountriesKit` is silently renamed + `CountriesKit_`; the entry point is `CountriesCore`. +- **The deployment floor is iOS 18** — generated coroutine support uses `Synchronization.Mutex`. +- **Kotlin is pinned to 2.4.20 for Swift export** (`KotlinTypedStateFlow`, `sealedType()`). +- **`export(...)` exports that module's API in full** and is the only way to set `flattenPackage`. + Export only modules free of the above. +- `SwiftNavigator`'s Swift API is **country codes, not `Screen`s** — keep Circuit out of Swift. + +## Linking SQLite + +- **`-lsqlite3` comes from Xcode's `OTHER_LDFLAGS`** — Swift export produces a static library, which + records no linker options. +- **`iosApp/Countries/Support/SQLiteLoadExtension.c` must stay.** It defines + `sqlite3_enable_load_extension`/`sqlite3_load_extension`, which no Apple SDK exports but SQLiter's + cinterop references. **Do not replace it with `-Wl,-U`** — that defers to a dyld crash before + `main()` on macOS (iOS survives only via dead-code stripping). **It cannot move into Kotlin**: + `@CName` is silently not emitted through Swift export, and a cinterop `.def` body is not compiled. + Check the macOS debug dylib for `` imports: + `dyld_info -imports …/Countries.app/Contents/MacOS/Countries.debug.dylib | grep flat-namespace`. + +## Commands + +``` +# Apple bridge — the Kotlin half of the SwiftUI app +./gradlew :apple:macosArm64Test :apple:iosSimulatorArm64Test + +# Inspect the generated Swift without going through Xcode. Swift export registers its tasks only +# when Xcode's environment variables are present, hence the prefix. Output lands in +# apple/build/SwiftExport//Debug/files/. +CONFIGURATION=Debug SDK_NAME=macosx ARCHS=arm64 TARGET_BUILD_DIR=/tmp/se \ +FRAMEWORKS_FOLDER_PATH=Frameworks ./gradlew :apple:macosArm64DebugSwiftExport + +# iOS / iPadOS / macOS app. Xcode runs the Gradle export itself, so open the project and hit run +# rather than building anything first. +open iosApp/Countries.xcodeproj +xcodebuild -project iosApp/Countries.xcodeproj -scheme Countries \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build +xcodebuild -project iosApp/Countries.xcodeproj -scheme Countries \ + -destination 'platform=macOS,arch=arm64' build + +# Unit tests and UI tests together. Run both destinations — several UI tests are device-shape +# specific and skip themselves on the shape they do not describe. +xcodebuild test -project iosApp/Countries.xcodeproj -scheme Countries \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' +xcodebuild test -project iosApp/Countries.xcodeproj -scheme Countries \ + -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.4' +``` + +**Tests run on iOS simulators only; the macOS destination is build-and-run.** `xcodebuild test` for +macOS fails with "Signing for CountriesUITests requires a development team" — Xcode builds every +testable in the scheme regardless of the target's `SUPPORTED_PLATFORMS` or of `-only-testing`, and a +macOS UI-test runner cannot be ad-hoc signed. Nothing is lost: the unit tests are pure functions +with no platform-specific behaviour, and they run on the simulator. + +## Verify + +- `./gradlew :apple:macosArm64Test :apple:iosSimulatorArm64Test`, then the iOS build — this is the + only check between a `model` or `dataresult` change and a broken iOS build. +- `iosApp/`: `xcodebuild test` on **both** the iPhone and iPad simulator destinations; the macOS + destination is build-and-run only. +- Switching branches that build the framework differently: wipe + `~/Library/Developer/Xcode/DerivedData/Countries-*` first. + +The reasons behind all of this, the Swift export bug write-ups and the test suites: the `apple-app` +skill. diff --git a/.claude/rules/web.md b/.claude/rules/web.md new file mode 100644 index 0000000..73f4b07 --- /dev/null +++ b/.claude/rules/web.md @@ -0,0 +1,64 @@ +--- +paths: + - "web/**" +--- + +# `:web` — the browser app (js + wasmJs) + +- **It does not apply `kmp-library`.** That convention adds android/jvm/apple targets and never + calls `binaries.executable()`, which is what turns a klib into a webpack bundle. + `web/build.gradle.kts` declares its two targets itself and wires `kotlin("test")` into + `commonTest` by hand. It still applies `formatting` and `metro`. +- **`commonMain` *is* the web source set.** With only js and wasmJs, `window`, `history` and DOM + types are usable from common code with no `expect`/`actual`. There is no `src/webMain`; adding one + buys nothing. `index.html`, `styles.css` and `sw.js` live in `src/commonMain/resources/`. +- **`main()` mounts via `ComposeViewport(viewportContainerId = "composeApp")`** + (`@ExperimentalComposeUiApi`, no `onWasmReady` wrapper needed). **The container must be sized by + CSS** — a zero-height container renders nothing, with no error. +- **`styles.css` duplicates the page colour from `WebSkin`** because it paints before any Kotlin + runs. Change one, change the other, or every cold load flashes the wrong colour. +- **Browser history is hand-written** in `BrowserHistory.kt` (Circuit has no web history, and CMP's + web `BackHandler` is not fed by `popstate`). `Routes.kt` owns the hash-route scheme (`#/`, + `#/country/{code}`). + - **Change navigation rules in `historyAction()` (`HistoryAction.kt`), which is pure and tested — + not in `BrowserHistory`,** which only executes the returned `HistoryAction`. The first + reconciliation *seeds* history from the backstack (`prevDepth == UNRECONCILED`), so a deep link + gets its list entry synthesised underneath it. + - `main()` hoists the backstack to bind it to `window.history`, which is why it passes + `circuitSaver = graph.circuitSaver` — `LocalCircuitSaver` is not in scope yet. +- **npm:** `devNpm("copy-webpack-plugin")` is declared per target (`npm()`/`devNpm()` exist only on + JS-family source sets). js and wasmJs have **separate lockfiles** — run both + `kotlinUpgradeYarnLock` and `kotlinWasmUpgradeYarnLock`. +- Both targets need **Chrome** installed to run and test. + +## The service worker — `sw.js`, registered from `ServiceWorker.kt` + +**This is what makes the app load offline**, not the Apollo cache — without it an offline reload +never fetches the bundle. + +| Request | Strategy | Why | +| --- | --- | --- | +| Same-origin `GET` | stale-while-revalidate | shell, hashed `.wasm` chunks, `composeResources` | +| `fonts.gstatic.com` | cache-first | immutable; keeps flags and non-Latin text from reverting to tofu offline | +| GraphQL `POST` | network-first, cache fallback | Cache API ignores POSTs, so keyed by a hash of the body | + +- **Nothing is precached** — filenames are content-hashed, so a manifest would rot. One online + visit is needed before offline works. +- **Bump `CACHE_VERSION` to evict everything.** +- Under `webpack-dev-server`, stale-while-revalidate can serve one-load-stale content; that is the + strategy working. **Verify offline behaviour against a distribution** served by a static file + server, not the dev server. + +## Commands + +``` +# Browser app — serves on http://localhost:8080 +./gradlew :web:wasmJsBrowserDevelopmentRun +./gradlew :web:jsBrowserDevelopmentRun +./gradlew :web:wasmJsBrowserDistribution # → web/build/dist/wasmJs/productionExecutable +./gradlew :web:jsBrowserDistribution # → web/build/dist/js/productionExecutable +``` + +## Verify + +`./gradlew :web:wasmJsBrowserDistribution :web:allTests` diff --git a/.claude/skills/add-screen/SKILL.md b/.claude/skills/add-screen/SKILL.md index 5985368..dd3d41d 100644 --- a/.claude/skills/add-screen/SKILL.md +++ b/.claude/skills/add-screen/SKILL.md @@ -7,8 +7,8 @@ description: End-to-end recipe for adding a Circuit screen to this project — S Two screens exist today: `CountryListScreen` and `CountryDetailScreen`. **Copy the closer of the two rather than working from this file alone** — it names the moving parts, but the existing pair is -the real reference. See `AGENTS.md` for the module map and `compose-previews` for the preview -annotations. +the real reference. See `AGENTS.md` for the module map. The rules in `.claude/rules/` for +`presenter/`, `ui/` and `composeResources/` load by themselves as you open those files. ## 1. Screen, state and events — `:presenter` @@ -126,7 +126,7 @@ fun ThingUi(state: ThingScreen.State, modifier: Modifier = Modifier) { - **`modifier: Modifier = Modifier` is the first optional parameter, and it goes on the root element** — not on something nested. This holds for every private helper in the file too, and - nothing in the build enforces it; the `compose-conventions` subagent is the check. + detekt's `ModifierMissing`/`ModifierNotUsedAtRoot` enforce it in `./gradlew build`. - **`:ui` has no `android.*` imports at all.** Keep it that way — there is no `AndroidView` escape hatch on five of the six platforms. - The one platform seam in `:ui` is `LocalFlagFontFamily`, null everywhere but desktop. Do not add @@ -136,40 +136,16 @@ fun ThingUi(state: ThingScreen.State, modifier: Modifier = Modifier) { ## 4. Strings and drawables -`ui/src/commonMain/composeResources/values/strings.xml` and `.../drawable/*.xml`, reached through -the generated `Res`, never AGP's `R`: +`ui/src/commonMain/composeResources/`, reached through the generated `Res`, never AGP's `R`. +`.claude/rules/compose-resources.md` loads when you open that directory. It covers the vector +drawable rules that fail only at runtime. Copy an existing drawable rather than importing one. -```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/…` theme attributes and no `@android:…` references.** -CMP's parser cannot resolve either, and **both fail at runtime rather than at build time.** Use -literal colours (`#FFFFFFFF`) and let `Icon` supply the real colour from `LocalContentColor`. The -existing drawables in that directory are all in the correct shape — copy one. - -## 5. Previews — required, and not just the happy path - -Every composable that emits UI needs a `@Preview`. Import -`androidx.compose.ui.tooling.preview.Preview`; use the project multipreviews from -`PreviewSupport.kt`: - -- `@AppScreenPreviews` on the whole screen — the full device-size sweep, once, for the happy path. -- `@PreviewLightDark` at phone size for the other states. -- `@ComponentWidthPreviews` on a strip inside a screen. +## 5. Previews: required, and not only the happy path -**Preview the states that are easy to break: loading, loaded, error, empty.** Reuse the fixtures in -`PreviewSupport.kt` (`loadedState`, `loadingState`, `refreshingState`, `failedState`, -`PreviewSurface`, and the sample `Country`/`CountryDetail`) — the sample list deliberately includes -a country with a wrapping name and a null capital, which is what breaks a row first. Read -`compose-previews` before adding one. +Every composable that emits UI needs a `@Preview`. `.claude/rules/compose-ui.md` loads with any +`:ui` source file and has the import, the two project multipreviews, the render budget and the +fixtures in `PreviewSupport.kt`. As a minimum, a new screen gets `@AppScreenPreviews` for the loaded +state and `@PreviewLightDark` for loading, error and empty. ## 6. Test the presenter — `:presenter` @@ -192,7 +168,7 @@ There is no Ui test layer in the Kotlin modules; UI behaviour is covered by the Add the destination to whatever navigates to it — usually a `navigator.goTo(ThingScreen(id))` from another presenter's event sink. If the screen should be reachable by URL in the browser, add it to `Routes.kt` in `:web` and to the precedence table in `historyAction()`; that function is pure and -tested, so **change the navigation rules there, not in `BrowserHistory`.** See `web-app`. +tested, so **change the navigation rules there, not in `BrowserHistory`.** See `.claude/rules/web.md`. ## 8. Verify diff --git a/.claude/skills/apple-app-icons/SKILL.md b/.claude/skills/apple-app-icons/SKILL.md deleted file mode 100644 index 36e251f..0000000 --- a/.claude/skills/apple-app-icons/SKILL.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -name: apple-app-icons -description: How the Apple app icon is derived from desktop/icons/icon.icns, and the different artwork macOS and iOS each require. Use when changing the app icon, editing iosApp/Countries/Assets.xcassets/AppIcon.appiconset, or touching desktop/icons/. An empty appiconset builds clean and silently produces an app with no icon, so read this before assuming the catalog is wired up. ---- - -# App icons on Apple - -The PNGs in `iosApp/Countries/Assets.xcassets/AppIcon.appiconset` are **derived from -`desktop/icons/icon.icns`**, the only file there carrying a 1024×1024 representation. They are -committed as plain images — Xcode has no build step that would produce them, and there is no -generator to run. Redo them by hand if the artwork changes; the recipe is below. - -macOS and iOS need materially different images out of that one source, which is the part to get -right: - -| | 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 | - -The ten macOS images are the source art unchanged, and come straight out of the icns: - -``` -iconutil -c iconset desktop/icons/icon.icns -o /tmp/icon.iconset -``` - -`icon_16x16.png` … `icon_512x512@2x.png` map onto the `mac-*` filenames one for one. - -**The iOS image is the one that needs work**, because iOS applies its own superellipse mask: handing -it the macOS art shows a rounded rect *inside* iOS's rounding with the corners going black, and App -Store validation rejects an icon carrying an alpha channel at all. Build it from -`icon_512x512@2x.png` (1024×1024) in four steps: - -1. **Crop to `(61, 61, 963, 963)`** — the opaque bounds of the rounded rect, i.e. the transparent - margin removed. Re-measure this if the artwork is redrawn; it is the alpha channel's bounding box. -2. **Scale that 902px crop to 1024×1024.** Not arbitrary: it leaves the globe at 729px, the same - 71.2% of the visible icon it occupies on macOS. -3. **Composite over an opaque vertical gradient**, `#785F98` at the top to `#584077` at the bottom — - the colours sampled at the rect's own top and bottom edges. The rect's rounded corners are still - transparent inside that crop, and this is what fills them seamlessly. -4. **Flatten to RGB**, so the file has no alpha channel at all. - -Verify the result is `RGB` and not `RGBA`, and that its four corner pixels equal the gradient -endpoints. `actool` will add a fully-opaque alpha channel to the compiled output, which is expected -and fine — validation looks at the source. - -**A missing image here fails silently.** An `.appiconset` whose `Contents.json` lists sizes but no -`filename` keys — Xcode's default placeholder, and what this was before — builds clean, emits no -warning, and simply produces an app with no icon. Verify by checking the built bundle rather than -the build log: `Countries.app/AppIcon60x60@2x.png` on iOS, `Contents/Resources/AppIcon.icns` on -macOS. Neither exists when the catalog is empty. - diff --git a/.claude/skills/apple-app/SKILL.md b/.claude/skills/apple-app/SKILL.md index 429bf06..6e3be99 100644 --- a/.claude/skills/apple-app/SKILL.md +++ b/.claude/skills/apple-app/SKILL.md @@ -8,6 +8,10 @@ description: The hand-written SwiftUI app for iOS, iPadOS and macOS and the :app The SwiftUI app and its Kotlin bridge. See `AGENTS.md` for the module map and the project-wide conventions this sits inside. +The short list of what must not break — sealed class vs interface, no Compose in the exported API, +the iOS 18 floor, the SQLite C file — is `.claude/rules/swift-export.md`, which loads by itself for +`apple/`, `iosApp/` and `model/`. This skill is the reasoning and history behind it. + ## The `apple` module The Kotlin half of the SwiftUI app, exported to Swift and linked by diff --git a/.claude/skills/compose-previews/SKILL.md b/.claude/skills/compose-previews/SKILL.md deleted file mode 100644 index e55f287..0000000 --- a/.claude/skills/compose-previews/SKILL.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: compose-previews -description: How @Preview works in this project's commonMain Compose code — which artifact and import to use, the two project multipreview annotations, and the shared fixtures. Use when adding or changing a @Preview, or when previews render nothing in Android Studio. Every composable that emits UI is required to have one. ---- - -# Previews - -Previews come from `org.jetbrains.compose.ui:ui-tooling-preview`, and the import is -**`androidx.compose.ui.tooling.preview.Preview`** — the AndroidX *names*, in `commonMain`. That -artifact is the Compose Multiplatform build of the AndroidX preview API: it publishes a variant for -every target this project has, and its common package is the `androidx` one, which is exactly why -Android Studio renders `commonMain` previews and why the AndroidX multipreviews -(`@PreviewScreenSizes`, `@PreviewLightDark`, `@PreviewFontScale`) and the full parameter list -(`widthDp`, `device`, `uiMode`, …) are available in common code. There is an older -`org.jetbrains.compose.ui.tooling.preview.Preview` from `components-ui-tooling-preview` — a bare -annotation with no parameters. Do not use it. - -Two project multipreviews in `ui/…/PreviewSupport.kt`, so no preview repeats a device spec: - -| Annotation | Use it on | Renders | -| --- | --- | --- | -| `@AppScreenPreviews` | whole screens | `@PreviewScreenSizes` (phone portrait/landscape, unfolded foldable, tablet portrait/landscape, desktop) plus a compact browser window | -| `@ComponentWidthPreviews` | a strip inside a screen | 360dp, 700dp, 1280dp | - -The same file holds `PreviewSurface` (wraps in `AppTheme` + `Surface`), sample `Country` / -`Continent` / `CountryDetail` fixtures, and `loadedState` / `loadingState` / `refreshingState` / -`failedState` for building a `ContentState`. Use them rather than inventing new fixtures — the -sample list deliberately includes a country with a wrapping name and a null capital, which is what -breaks a row first. - -Budget renders: the happy path gets the full size sweep, other states get `@PreviewLightDark` at -phone size. `AppTheme` reads `isSystemInDarkTheme()`, which the renderer drives from `uiMode`, so -`@PreviewLightDark` needs nothing passed to it. - -`PreviewSurface` is the one composable that emits UI without a `@Preview` of its own — it *is* the -preview harness, so previewing it would be circular. - -**`ui/build.gradle.kts` declares the renderer as `androidRuntimeClasspath`, not -`implementation`.** Android Studio resolves `ComposeViewAdapter` from the module's own runtime -classpath, so the annotations alone draw nothing; `androidRuntimeClasspath` is resolvable-only and -not a published variant, so the renderer is there for the IDE and never reaches `:app`. The AGP KMP -library plugin has no build types, so there is no `debugImplementation` to scope it with. - diff --git a/.claude/skills/dependency-bump/SKILL.md b/.claude/skills/dependency-bump/SKILL.md index 04ad1e0..eb38d10 100644 --- a/.claude/skills/dependency-bump/SKILL.md +++ b/.claude/skills/dependency-bump/SKILL.md @@ -1,6 +1,6 @@ --- name: dependency-bump -description: How Compose, Kotlin and the version catalog are pinned in this project, and how to change one without silently breaking another. Use when editing gradle/libs.versions.toml, bumping the Compose BOM or Compose Multiplatform, changing material3, or adding an npm dependency to a web target. Several of the constraints here fail with no build error at all — a wrong Compose version renders tofu in the browser and a wrong BOM silently drags material3 backwards. +description: How to bump Compose, Kotlin or the Compose BOM in this project and prove nothing else moved — the AndroidX/CMP split, the two dependency verifications, and the after-bump checklist. Use when editing gradle/libs.versions.toml, bumping the Compose BOM or Compose Multiplatform, changing material3, or adding an npm dependency to a web target. Several of the constraints here fail with no build error at all — a wrong Compose version renders tofu in the browser and a wrong BOM silently drags material3 backwards. --- # Bumping dependencies @@ -41,35 +41,19 @@ Compose. **It must never go in `commonMain`:** `androidx.compose.runtime` is gen multiplatform and reaches jvm/native/web through CMP's thin alias, so a common-scoped BOM would drag those onto the AndroidX line too. -## The four rules that fail silently +## The invariants live in a rule -**1. Never drop `composeMultiplatform` below 1.12.** 1.12 is where Compose Multiplatform gained -automatic fallback-font loading in the browser. Dropping below it reintroduces the bug with no -error: everything builds, and only a human looking at the running page notices that every emoji and -every non-Latin script renders as tofu. Read the `compose-fonts` skill before even considering it. +`.claude/rules/build-scripts.md` loads by itself whenever a build script, the catalog or a lockfile +is read. It holds the silent-failure list: the 1.12 floor, material3 on its own line, the BOM kept +out of `commonMain` and material3 kept out of the BOM, circuit+metro bumped together, the Compose +module build requirements, the Kotlin pin and the two lockfiles. This skill covers what a rule +cannot: why the split exists, and how to prove a bump did not move anything. -**2. material3 is on its own version line — `composeMaterial3`, not `composeMultiplatform`.** It -does not track the core version and never has; the CMP plugin's own `compose.material3` accessor -pins something far behind, which would drag AndroidX material3 *backwards* several minor lines. -Always set `composeMaterial3` explicitly, and when changing it check two things: which CMP core -version it requires, and which AndroidX material3 it aliases. The same reasoning applies to -`composeMaterial3Adaptive`, which is also on its own line. - -**3. material3 is deliberately outside the BOM.** The BOM manages it at 1.4.0, older than the alpha -line this project tracks. That is harmless *because* a direct dependency with an explicit version -beats a lower BOM constraint: `:app` resolves 1.5.0-alpha26 from the catalog and `:ui` resolves -1.5.0-alpha22 through CMP's material3. Both resolve **up** from 1.4.0, never back. - -**Re-check that after every BOM bump** — if a future BOM pins material3 *higher* than the alpha -line, the BOM silently wins and the project moves backwards from the alpha it meant to track. - -Those two numbers differing is expected, not skew: material3 is on its own line by design, and -`:ui` gets it via Compose Multiplatform while `:app` declares AndroidX directly. - -**4. `platform(...)` does not exist on a KMP source-set dependency handler.** It is not Gradle's -`DependencyHandler`, so an `androidMain.dependencies { }` block needs -`project.dependencies.platform(...)`. A bare `platform(...)` fails with `Unresolved reference` — -this one at least fails loudly, but the fix is not obvious. +**material3 resolves up, never back.** The BOM manages it at 1.4.0, older than the alpha line this +project tracks, and a direct dependency with an explicit version beats a lower BOM constraint. `:app` +resolves the catalog's AndroidX version, and `:ui` resolves CMP's material3. The two numbers differ +by design. **After every BOM bump, re-check that the BOM has not pinned material3 above the alpha +line.** If it has, the BOM wins silently. ## Verify — both configurations, every time @@ -88,75 +72,6 @@ described above, not a finding. **Delegate this to the `gradle-runner` subagent's dependency-verification mode.** The raw output is thousands of lines and the answer is six numbers. -## Three build requirements that are easy to miss - -- **A Compose module must apply `alias(libs.plugins.compose.multiplatform)`**, even though every - dependency is declared by catalog coordinate rather than through `compose.*` accessors. - `compose.foundation` pulls `compose.ui`, which on js and wasmJs depends on - `org.jetbrains.skiko:skiko`; that plugin is what configures skiko's web packaging. Keep - `org.jetbrains.kotlin.plugin.compose` applied alongside it — on Kotlin 2.x the CMP plugin expects - the Compose compiler plugin to be applied separately. -- **`org.jetbrains.compose.experimental.macos.enabled=true` in `gradle.properties`.** `macosArm64` - is in the target list and the CMP plugin refuses to configure it without this opt-in, failing at - configuration time with "Compose targets '[macos]' are experimental". -- **A module with `composeResources` needs `android { androidResources { enable = true } }`** - inside its `kotlin { }` block — see `ui/build.gradle.kts`. The KMP Android plugin disables - resource processing by default, which leaves `variant.sources.assets` unavailable, and that is - exactly where Compose Multiplatform packages resources on Android. Without it everything - compiles, the APK simply has no `assets/composeResources/`, and the app dies on first use with - `MissingResourceException`. Nothing warns at build time. - -Also: **add `binaries.executable()` to the `js` and `wasmJs` targets of any Compose *library* -module** — see `presenter/build.gradle.kts`. CMP 1.12 added -`checkComposeUiTestConfigurationFor{Js,WasmJs}`, which hard-fails any module whose browser test -bundle reaches skiko without an executable binary to bundle it into. It fires off the target's test -task existing, not off there being test sources, and there is no opt-out property. - -## Kotlin, AGP and the plugin classpath - -- **`kotlin` is pinned to 2.4.20 for Swift export**, not for anything on the JVM side. `StateFlow` - arriving as `KotlinTypedStateFlow` and sealed types getting a generated `sealedType()` both - landed after 2.4.10. Dropping below 2.4.20 costs the two things that make the exported API usable - from Swift at all — read the `apple-app` skill before touching it. -- **AGP 9 has built-in Kotlin support**, so Android modules must **not** apply - `org.jetbrains.kotlin.android`; AGP fails the build if they do. -- The root buildscript classpath **forces** the KGP and Compose compiler plugin versions Metro - needs. Modules apply the remaining Kotlin-family plugins (`plugin.compose`, `plugin.parcelize`, - `plugin.serialization`) by id with no version, picking up those classpath versions. Bumping - `kotlin` in the catalog without checking that force is how you get a Metro/KGP mismatch. -- **`circuit` and `metro` are coupled**, because Metro is what generates Circuit's codegen — not - just `@CircuitInject`'s factories but, since Circuit 0.38, the `CircuitSerializerRegistration` for - each `@CircuitSerializable` screen. Metro 1.4.2 is the floor for the latter. An older Metro - compiles the annotation and silently contributes nothing, which surfaces as a throw the first - time a back stack is saved. Bump the two together and run `./gradlew :shared-compose:allTests`, - which is the only task that notices. -- All modules target **Java 17**, from `Versions` in `build-logic`. A module script can import it - directly — `import io.github.solcott.countries.build.Versions` — because `Versions.class` rides - in the same `build-logic.jar` as the plugin descriptors. `build-logic`'s own `jvmToolchain(25)` - is a different fact: that is the JVM the convention plugins compile against, matching the daemon, - not the modules' target. -- The daemon JVM is pinned in the root `build.gradle.kts`. After changing it, run - `./gradlew updateDaemonJvm` to regenerate `gradle/gradle-daemon-jvm.properties`, which is - committed. - -## npm dependencies and the two web lockfiles - -`kotlin-js-store/` holds **two** committed lockfiles, because js and wasmJs have separate npm -stores: `yarn.lock` for js and `wasm/yarn.lock` for wasmJs. Adding an npm dependency needs both. - -``` -./gradlew kotlinUpgradeYarnLock # js -./gradlew kotlinWasmUpgradeYarnLock # wasmJs -``` - -Regenerate rather than editing by hand. A build that touches only one store fails with "Lock file -was changed" naming only that task, so it is easy to fix one and forget the other — and the second -failure then looks like a new problem. Adding *tests* can also move a lockfile, because the JS test -link pulls in packages the main compilation did not. - -`devNpm("copy-webpack-plugin")` is declared per target rather than once, because `npm()`/`devNpm()` -are only available to JS-family source sets. - ## After any bump 1. `./gradlew ktfmtCheck test assembleDebug` for the JVM and Android side. diff --git a/.claude/skills/desktop-app/SKILL.md b/.claude/skills/desktop-app/SKILL.md index 2dad567..080236e 100644 --- a/.claude/skills/desktop-app/SKILL.md +++ b/.claude/skills/desktop-app/SKILL.md @@ -1,72 +1,12 @@ --- name: desktop-app -description: The Windows/Linux/macOS Compose app (:desktop). Use when editing anything under desktop/, changing jpackage or uber-jar packaging, touching the keyboard back shortcut, or running the app under Compose Hot Reload to look at the UI. Covers why it is a plain kotlin("jvm") module, why nativeDistributions { modules(...) } fails invisibly — a missing JDK module never shows up in `run`, only in an installed build — and why the window's minimum size now comes from the experimental window v2 API. +description: Running the desktop app (:desktop) under Compose Hot Reload and driving it through the hot-reload MCP server — the fastest way to see a UI change on any platform here, and the only way to screenshot or click rendered Compose UI from a terminal. Use when you need to look at the UI, check a layout breakpoint, or iterate on anything in :ui visually. The invariants for editing desktop/ itself (jlink modules, packaging, the dock icon) are in .claude/rules/desktop.md, which loads on its own. --- -# The `desktop` module +# Looking at the UI through `:desktop` -The Windows/Linux/macOS app. It is the smallest of the three entry points, because everything that -made `:web` interesting — history, a service worker, npm — the JVM either has already or does not -need. Five things are worth knowing: - -- **It is a plain `kotlin("jvm")` module, not multiplatform.** Desktop *is* the jvm target, so - `kotlin { }` would hold exactly one target and `src/jvmMain` would be a directory with nothing to - distinguish it from `src/main`. `:web` is multiplatform because it genuinely serves two targets - from one module. Like `:web` it does not apply `kmp-library`, and like `:web` it declares - `kotlin("test")` and the JVM toolchain itself, since no convention is doing it. -- **`compose.desktop.currentOs` is the one dependency declared through a plugin accessor rather - than a catalog coordinate.** It has to be: skiko's runtime jar is classified by OS *and* - architecture, and only the accessor picks the right one. **The consequence is that everything - built here runs on the build host's OS only** — including `packageUberJarForCurrentOS`. Real - cross-platform installers need the packaging task run on each OS, because jpackage cannot - cross-build either; that is a CI matrix, and this repo has no CI yet. -- **`nativeDistributions { modules(...) }` is load-bearing and fails invisibly.** jpackage jlinks a - trimmed JDK, and the default module set has neither `java.sql`/`jdk.unsupported` (sqlite-jdbc, - under the Apollo cache) nor `java.naming`/`jdk.crypto.ec` (OkHttp's TLS). `run` uses the full - JDK, so a missing module never shows up in development — only in an installed build, as a crash - on the first query. Test packaging changes with `packageDistributionForCurrentOS`, not `run`. -- **The window comes from `androidx.compose.ui.window.v2`, not the stable window API.** That is - what makes `minSize` a parameter of `Window` — before it, the 480×600 floor could only be reached - through AWT, as `window.minimumSize = …` inside a `LaunchedEffect`, because the v1 `WindowState` - could not express a minimum at all. `WindowPositionProvider.CenteredOnScreen` and - `WindowSizeProvider.Fixed` replace `WindowPosition(Alignment.Center)` in the same swap. The cost - is `@OptIn(ExperimentalComposeUiApi::class)` and the API's own warning that it "may be moved to - `androidx.compose.ui.window` before stabilization" — when that happens, only `Main.kt`'s imports - change. Verified live: the window opens at 1100×800 centered, and AWT reports - `minimumSize=480x600`, so the parameter really does reach the peer window. -- **Keyboard back is `isBackShortcut()` in `BackShortcut.kt`**, pure and tested, for the same - reason `historyAction()` is: a rule welded to a `KeyEvent` cannot be tested without a window. The - backstack is hoisted out of `CountriesApp` so `Window`'s `onKeyEvent` can reach it. `onRootPop` - is deliberately left at its default no-op — the close button is how you leave a desktop app, and - Esc on the root screen should not quit it. -- **Hoisting the backstack is why `Main.kt` names a `CircuitSaver`.** `rememberSaveableBackStack` - otherwise takes one from `LocalCircuitSaver`, which `CountriesApp` provides via - `CircuitCompositionLocals` — but the hoisted stack is built *before* that, so it has to be passed - `circuitSaver = graph.circuitSaver`. That accessor exists on `ComposeGraph` for this call site and - `:web`'s; every other consumer should let the local supply it. Leave the argument off and the - build fails at the read, not at runtime, so this one at least announces itself. - -Icons live in `desktop/icons/` and are the source of truth for the app icon **on every platform**: -jpackage reads all three from disk, `icon.png` is also on the runtime classpath, and the Apple asset -catalog is derived from `icon.icns` — see `.claude/skills/apple-app-icons/SKILL.md`. -`build.gradle.kts` adds that directory as a resource root and excludes `*.icns`/`*.ico` from the -jar, since only the PNG is useful at runtime. - -**`Window(icon = …)` is not a dock icon, and looks like one.** Compose Desktop's `icon` parameter -resolves to `java.awt.Window.setIconImage` — a *title bar* icon, which is what Windows and Linux -want and which macOS has no concept of. macOS takes the dock icon from the app bundle or from -`java.awt.Taskbar`, and Compose Desktop references `Taskbar` nowhere. The consequence was that a -packaged build looked right — jpackage writes `Countries.icns` and `CFBundleIconFile` into the -bundle — while `:desktop:run` and the uber jar, i.e. every development launch, showed the default -Java coffee cup. `applyTaskbarIcon()` in `AppIcon.kt` is what sets it, called from `main()` before -the first window; it no-ops off macOS, where `Feature.ICON_IMAGE` is unsupported and `Window` -already does the job. - -No build task can see a dock icon, so this is a `:desktop:run`-and-look check. Test packaging -separately with `packageDistributionForCurrentOS` — the bundle icon and the runtime one come from -different mechanisms and either can regress without the other. - -The flag font `:desktop` bundles is a separate concern — see `.claude/skills/compose-fonts/SKILL.md`. +Editing `desktop/` itself: `.claude/rules/desktop.md` loads automatically and carries the +invariants — packaging, `nativeDistributions`, the window v2 API, the dock icon. ## Compose Hot Reload, and the MCP server diff --git a/.claude/skills/network-apollo/SKILL.md b/.claude/skills/network-apollo/SKILL.md deleted file mode 100644 index e7ed05e..0000000 --- a/.claude/skills/network-apollo/SKILL.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -name: network-apollo -description: The :network module — the Apollo Kotlin client, the .graphql operations, the normalized cache, and the hand-written SQL.js IndexedDB worker the web targets use. Use when editing anything under network/, changing a GraphQL operation, touching the cache configuration, or working on the web worker in network/npm/. Covers why the worker cannot move into webMain and why that failure appears in a task neither web target runs. ---- - -# The network module - -Apollo Kotlin, the GraphQL operations, and the normalized cache. See `AGENTS.md` for the module map -and the project-wide conventions this sits inside. - -The endpoint is the public API at `https://countries.trevorblades.com/`. - -## The Apollo Gradle plugin needs almost no wiring - -The plugin detects the KMP plugin by itself. It reads operations from `src/commonMain/graphql/` and -attaches the generated code to `commonMain` — no `srcDir` or output wiring is needed in -`network/build.gradle.kts`. It also adds `-lsqlite3` to native binaries once it sees a -`normalized-cache-sqlite` dependency. - -**Apollo generated code is build output.** Never hand-edit it, never commit it, and never import -anything from the generated package outside `:network`. `network` owns the mapping from the -generated GraphQL data classes to `model` types and returns only the latter. To change what is -fetched, change the `.graphql` operation files. - -## The normalized cache, and where each platform puts it - -`SqlNormalizedCacheFactory(name)` is an `expect` function *in Apollo*, so it compiles on every -target. What differs is where it stores data: - -| Target | Storage | -| --- | --- | -| Android | `cacheDir`, via an `androidx.startup` initializer shipped in the AAR | -| JVM | `~/.apollo` | -| Apple | Application Support | -| js / wasmJs | SQLDelight's SQL.js web-worker driver — **and the name is ignored** | - -The web driver needs two npm dependencies, declared on `jsMain` and `wasmJsMain` in -`network/build.gradle.kts`. **Pin them to the SQLDelight version that `normalized-cache-sqlite` -actually depends on (currently 2.1.0), not to the latest.** - -A browser **application** module additionally needs a `webpack.config.d/` entry copying `sql.js`'s -`.wasm` into the bundle — see `web/webpack.config.d/sqljs.js`. Nothing in the Kotlin sources -references that file, so without the copy step the build is clean and the worker 404s at runtime. -That is not required for a library module, so `:network` does not have one. - -## The SQL.js worker - -**Web uses its own SQL.js worker, and `createDefaultWebWorkerDriver()` must not come back.** SQL.js -has no storage of its own — the database is a block of memory you are responsible for saving — and -the reference worker, `@cashapp/sqldelight-sqljs-worker`, does `new SQL.Database()` and never -writes it anywhere. On that worker the SQLite tier is a second in-memory cache behind the first -one, at the cost of a 600 KB wasm blob. - -`network/npm/countries-sqljs-idb-worker/` is that worker with a persistence layer: it loads the -database from IndexedDB at startup and writes `db.export()` back, debounced, after each -transaction. `NetworkProviders.{js,wasmJs}.kt` build the `WebWorkerDriver` around it by hand. - -Four things about it are easy to break: - -- **It is a local npm package, not a loose `.js` file.** `new Worker(new URL(…))` has to resolve at - bundle time, and a bare specifier out of `node_modules` is the only shape that works from a - library module. Both `jsMain` and `wasmJsMain` declare it. -- **The `exec` response must stay `res[0] ?? { values: [] }`.** `db.exec` also returns `[]` for a - `SELECT` that matched nothing, so returning anything richer — a rows-modified count, say — makes - a cache miss look like a row to SQLDelight's cursor. -- **The database name travels as the worker's own name** (`new Worker(url, { name })`), because - SQLDelight's message protocol has no field for it. It keys the IndexedDB snapshot. -- **The `Worker` must not move up into `webMain`.** `WebWorkerDriver` takes SQLDelight's - `expect class Worker`, actualised as a typealias to `org.w3c.dom.Worker`. A typealias only - expands in a *platform* compilation, so js and wasmJs both accept a `Worker` there while - `compileWebMainKotlinMetadata` — which also compiles that source set — sees an opaque expect - class and fails the argument. That is why the seam is `persistentSqlJsDriver(): SqlDriver`: - `SqlDriver` is an ordinary common type. - -That last one is the trap worth remembering: **the failure blocks `assemble` for `:network` and -everything above it, and neither web target's own compile task reproduces it.** Verify a change -here with `./gradlew :network:assemble`, not with `:network:compileKotlinJs`. - -**Persisting the cache is not what makes the app work offline.** The service worker does — see the -`web-app` skill. The persistent cache only helps once the page is already running. - -## The per-platform seam - -Per-platform Apollo client configuration goes through -`ApolloClient.Builder.platformConfiguration()`, an `expect` extension in `network/src/commonMain`. -Everything that does not vary — the endpoint, the in-memory cache tier, the Metro provider itself — -stays in `commonMain`. **Add new per-platform concerns (HTTP engines, interceptors) to that seam -rather than forking the provider.** - -`NetworkProviders` carries `@ContributesTo(AppScope::class)`, so it is picked up by graph -aggregation from wherever `:network` sits on the compile classpath. It must stay `api` on the graph -module, not `implementation` — see the Metro rules in `AGENTS.md`. - -## Logging - -Take Kermit as an `implementation` dependency and accept a `Logger` as a parameter; never hold one -in a file-level `private val` and never reach for it as a global. `mapToOutcome(logger, …)` is the -shape for a free function. `MappersTest` in `:repository` pins, via `kermit-test`'s `TestLogWriter`, -that failures are logged with their throwable and that cache misses and GraphQL errors are not — -that assertion is only possible because the logger is injected. - -## Verifying a change here - -- `./gradlew :network:assemble` — the one that catches the `webMain` metadata trap. -- `./gradlew :repository:allTests` — where the mapping tests live; `:network` itself has no tests. -- If the web worker changed, run the browser app and confirm the IndexedDB snapshot survives a - reload: `./gradlew :web:wasmJsBrowserDevelopmentRun`. diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index 540c4af..990c746 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -29,7 +29,7 @@ Start from `git status --short` / `git diff --name-only`. Take the union of ever | --- | --- | --- | | `model/` | `:model:assemble` and `:apple:macosArm64Test` | Exported to Swift **in full**. A Compose type or a generic sealed type added to it breaks the iOS build and nothing else warns you. | | the `dataresult` version in `libs.versions.toml` | `:apple:macosArm64Test`, then the iOS build | `io.github.solcott:dataresult` and `:uistate` are exported to Swift in full too, and their contents now live in another repo. A bump can introduce the Compose type or generic sealed interface that breaks the export, and no task in this build will mention it. | -| `network/` | **`:network:assemble`** and `:repository:allTests` | `:network` has no tests of its own. `assemble` is the task that catches the `compileWebMainKotlinMetadata` `Worker` failure — neither web target's own compile task reproduces it. See `network-apollo`. | +| `network/` | **`:network:assemble`** and `:repository:allTests` | `:network` has no tests of its own. `assemble` is the task that catches the `compileWebMainKotlinMetadata` `Worker` failure — neither web target's own compile task reproduces it. See `.claude/rules/network.md`. | | `repository/` | `:repository:allTests` | | | `presenter/` | `:presenter:allTests` and `:shared-compose:allTests` | The second one is for `Screen` changes: a screen missing `@CircuitSerializable`, or carrying a property with no serializer, builds clean and throws only when a back stack is saved. | | `ui/` | `:ui:assemble` and `assembleDebug` | `:ui` has no tests; `assemble` proves it compiles on all six targets, `assembleDebug` proves the Android app still links. | @@ -41,7 +41,7 @@ Start from `git status --short` / `git diff --name-only`. Take the union of ever | `iosApp/` | `xcodebuild` on **both** simulator destinations | Several UI tests are device-shape specific and skip themselves on the other shape. | | `gradle/libs.versions.toml`, any `build.gradle.kts` | See `dependency-bump` | Compose or BOM changes need both dependency verifications. | | `kotlin-js-store/`, an npm dependency | `kotlinUpgradeYarnLock` **and** `kotlinWasmUpgradeYarnLock` | Two separate stores. A build touching one fails naming only that task, so fixing one and forgetting the other is the usual cause of the next failure. | -| `.claude/skills/`, `AGENTS.md`, `README.md` | Nothing | Documentation. Confirm `git status` shows no source changes and stop. | +| `.claude/skills/`, `.claude/rules/`, `AGENTS.md`, `README.md` | Nothing | Documentation. Confirm `git status` shows no source changes and stop. | ## The broad sweep diff --git a/.claude/skills/web-app/SKILL.md b/.claude/skills/web-app/SKILL.md deleted file mode 100644 index 94e2ad5..0000000 --- a/.claude/skills/web-app/SKILL.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -name: web-app -description: The browser app (:web), which targets js and wasmJs from one module, plus the service worker that makes it load offline. Use when editing anything under web/, touching browser history and routing, the SQL.js worker, npm/yarn lockfiles for the web targets, or sw.js. Covers why commonMain is the web source set and why nothing is precached. ---- - -# The web app - -Browser entry point and offline behaviour. See `AGENTS.md` for the module map and the project-wide -conventions this sits inside. - -## The `web` module - -The browser app, targeting **both** `js` and `wasmJs` from one module. Four things about it are -not obvious: - -- **It does not apply `kmp-library`.** That convention is for libraries: it adds android, jvm, - ios and macos targets, and it never calls `binaries.executable()` — which is what turns a klib - into a webpack bundle. `web/build.gradle.kts` declares its two targets itself — and, because - the convention is not there to do it, wires `kotlin("test")` into `commonTest` by hand. It - still applies - `formatting`, and it applies `metro` so `createGraph()` resolves, exactly as - `:app` does. -- **`commonMain` *is* the web source set.** With only js and wasmJs on the module, the metadata - compilation resolves against `kotlinx-browser` and `org.w3c.dom`, so `window`, `history` and - the DOM event types are usable from common code with no `expect`/`actual` — the same thing - Compose Multiplatform does in its own `webMain`. There is no `src/webMain` here, and adding one - would buy nothing. `index.html` and `styles.css` live in `src/commonMain/resources/` and both - target distributions pick them up. -- **`main()` mounts through `ComposeViewport(viewportContainerId = "composeApp")`** from - `androidx.compose.ui.window`, which is `@ExperimentalComposeUiApi`. It waits for the DOM and, - on wasm, for the runtime, so no `onWasmReady` wrapper is needed. The container must be sized by - CSS — Compose measures its viewport from the element, and a zero-height container renders - nothing with no error. -- **Browser history is hand-written**, in `BrowserHistory.kt`. Circuit ships no web history - integration, and Compose Multiplatform's web `BackHandler` is driven by a - `NavigationEventDispatcher` that browser `popstate` does not feed. The binding is - bidirectional: pushes become `pushState`, in-app pops become `history.back()`, and `popstate` - drives the backstack. `Routes.kt` owns the URL scheme — hash routes (`#/`, - `#/country/{code}`), because a static bundle has no server to rewrite paths back to - `index.html`. - - **The decision itself lives in `historyAction()` (`HistoryAction.kt`), which is pure and - tested — change the navigation rules there, not in the effect.** `BrowserHistory` only - executes the `HistoryAction` it returns. That split exists because the rule set is a six-way - precedence table that produced two bugs while it was welded to `window.history` and therefore - untestable. Note especially that the first reconciliation *seeds* history from the backstack - (`prevDepth == UNRECONCILED`): the document has one entry however deep the URL seeded the - backstack, so a deep link needs the list synthesised underneath it. - - Binding to `window.history` is also why `main()` hoists the backstack out of `CountriesApp`, and - hoisting is why it has to name `circuitSaver = graph.circuitSaver`: the stack is built before - `CountriesApp` mounts `CircuitCompositionLocals`, so `LocalCircuitSaver` is not in scope yet. - `:desktop` is the only other call site that needs it. - -Both web targets need **Chrome** installed to run, and `devNpm("copy-webpack-plugin")` is -declared per target because `npm()`/`devNpm()` are only available to JS-family source sets. -The js and wasm npm stores have **separate lockfiles and separate upgrade tasks** — -`kotlinUpgradeYarnLock` and `kotlinWasmUpgradeYarnLock`. Adding an npm dependency needs both. - -Also add `binaries.executable()` to the `js` and `wasmJs` targets of any **Compose library** -module — see `presenter/build.gradle.kts`. CMP 1.12 added -`checkComposeUiTestConfigurationFor{Js,WasmJs}`, which hard-fails any module whose browser test -bundle reaches skiko without an executable binary to bundle it into. It fires off the target's -test task existing, not off there being test sources, and there is no opt-out property. - - -## Offline, and the service worker - -`web/src/commonMain/resources/sw.js`, registered from `ServiceWorker.kt`. **This is the thing that -makes the app load with no network at all.** The persistent Apollo cache only helps once the page -is running; without a service worker an offline reload never gets that far, because the bundle -itself cannot be fetched. - -| Request | Strategy | Why | -| --- | --- | --- | -| Same-origin `GET` | stale-while-revalidate | The app shell, the hashed `.wasm` chunks, `composeResources` | -| `fonts.gstatic.com` | cache-first | Immutable, and what keeps flags and non-Latin text from reverting to tofu offline | -| GraphQL `POST` | network-first, cache fallback | The Cache API ignores POSTs, so responses are keyed by a hash of the request body | - -**Nothing is precached.** The bundle filenames are content-hashed, so a hard-coded manifest would -rot on every build; the shell is cached as it is first requested instead. The cost is that the app -needs one online visit before it works offline. - -Two practical notes: - -- **Bump `CACHE_VERSION` to evict everything.** `activate` deletes every cache that is not current. -- **Under `webpack-dev-server`, stale-while-revalidate can serve one-load-stale content.** That is - the strategy working, not a build bug — `skipWaiting()` means the next reload picks up the new - bundle. Verify offline behaviour against a *distribution* served by a static file server, not - against the dev server. - diff --git a/.github/renovate.json5 b/.github/renovate.json5 index ea695a3..c05e331 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -16,7 +16,7 @@ // the latest -- the web-worker-driver and the hand-written SQL.js worker's message protocol // must agree with it. Renovate can't see that transitive pin, so it keeps proposing bumps // that float SQLDelight ahead of Apollo's cache. Bump this by hand when apollo-normalized-cache - // moves. See the network-apollo skill. + // moves. See .claude/rules/network.md. description: "SQLDelight is pinned to what apollo-normalized-cache-sqlite requires. Bump it by hand alongside the cache.", matchPackageNames: ["app.cash.sqldelight:*"], enabled: false, @@ -27,7 +27,7 @@ // glue). Both must be the same version or the Emscripten glue loads a mismatched .wasm and // initSqlJs() hangs forever -- a silent spinner on the web app. Renovate only sees the // package.json half, so an automated bump breaks the build. Bump both by hand and verify on - // web. See network/build.gradle.kts and the network-apollo skill. + // web. See network/build.gradle.kts and .claude/rules/network.md. description: "sql.js is version-declared in two places (a Gradle npm() call and the worker package.json) that must match; Renovate sees only one. Bump both by hand.", matchPackageNames: ["sql.js"], enabled: false, diff --git a/.gitignore b/.gitignore index 8d556ad..ac7bb51 100644 --- a/.gitignore +++ b/.gitignore @@ -18,11 +18,13 @@ xcuserdata/ *.xcscmblueprint .swiftpm/ -# Claude Code. `skills/`, `agents/` and `settings.json` are project documentation and configuration -# and are committed — AGENTS.md routes to the skills by path, the agents encode this build's traps -# and output contracts, and all three are reviewable in a PR like any other doc. Everything else +# Claude Code. `rules/`, `skills/`, `agents/` and `settings.json` are project documentation and +# configuration and are committed — the rules load by path and hold the invariants that fail +# silently, AGENTS.md routes to the skills by task, the agents encode this build's traps and output +# contracts, and all four are reviewable in a PR like any other doc. Everything else # here — `settings.local.json` above all — is per-user state. .claude/* +!.claude/rules/ !.claude/skills/ !.claude/agents/ !.claude/settings.json diff --git a/AGENTS.md b/AGENTS.md index a17094a..6171254 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,25 +5,40 @@ https://countries.trevorblades.com/ and displays them. ## Read first -Per-platform detail lives in skills rather than here, so this file stays the part that applies to -every task. **Read the matching skill before editing, not after** — most of what they document -fails silently, so the cost of skipping one is a broken build you do not notice. +Most of what is easy to break here fails **silently** — a clean build and a broken app. That +material is kept next to the code it governs, in two places: -| Touching | Read | +**Path rules, `.claude/rules/*.md`.** Each one declares the paths it covers in `paths:` frontmatter, +and Claude Code loads it automatically when a matching file is read. Other agents: open the rule for +the directory you are editing *before* editing it. + +| Touching | Rule | +| --- | --- | +| `apple/`, `iosApp/`, `model/` (exported to Swift in full) | `swift-export.md` | +| `desktop/icons/`, `AppIcon.appiconset` | `app-icons.md` | +| `web/` | `web.md` | +| `desktop/` | `desktop.md` | +| `network/` | `network.md` | +| `ui/**/*.kt` — modifiers, previews, the one platform seam | `compose-ui.md` | +| `composeResources/` | `compose-resources.md` | +| `presenter/` | `screens.md` | +| `shared/`, `shared-compose/` — the Metro graphs | `metro-graph.md` | +| `libs.versions.toml`, any build script, `build-logic/`, lockfiles | `build-scripts.md` | +| any test source set | `kmp-tests.md` | + +**Skills, `.claude/skills/*/SKILL.md`**, for tasks rather than paths: + +| Doing | Skill | | --- | --- | -| `iosApp/`, `apple/`, or anything Swift export | `.claude/skills/apple-app/SKILL.md` | -| the app icon, `desktop/icons/`, `Assets.xcassets` | `.claude/skills/apple-app-icons/SKILL.md` | -| `web/`, browser history, the service worker | `.claude/skills/web-app/SKILL.md` | -| `desktop/`, jpackage, the uber jar | `.claude/skills/desktop-app/SKILL.md` | -| flags, emoji, or non-Latin text rendering | `.claude/skills/compose-fonts/SKILL.md` | -| adding or changing a `@Preview` | `.claude/skills/compose-previews/SKILL.md` | -| `network/`, a `.graphql` operation, the SQL.js worker | `.claude/skills/network-apollo/SKILL.md` | -| `gradle/libs.versions.toml`, the Compose BOM, an npm dependency | `.claude/skills/dependency-bump/SKILL.md` | -| adding a Circuit screen | `.claude/skills/add-screen/SKILL.md` | -| deciding what to build or test before committing | `.claude/skills/verify/SKILL.md` | -| GitHub PR review comments, rebasing the stack | `.claude/skills/pr-review/SKILL.md` | - -They are ordinary markdown — open the path directly if the skill mechanism is not available. +| adding a Circuit screen | `add-screen` | +| deciding what to build or test before committing | `verify` | +| bumping Compose, Kotlin or the BOM | `dependency-bump` | +| flags, emoji, or non-Latin text rendering wrong | `compose-fonts` | +| the reasoning behind the Swift export rules, the Apple test suites | `apple-app` | +| looking at the UI under Compose Hot Reload | `desktop-app` | +| GitHub PR review comments, rebasing the stack | `pr-review` | + +Both are ordinary markdown — open the path directly if neither mechanism is available. ## Tech stack @@ -42,15 +57,12 @@ They are ordinary markdown — open the path directly if the skill mechanism is | Screen persistence | `@CircuitSerializable` + a `SerializableCircuitSaver` — see the `add-screen` skill | | Logging | [Kermit](https://kermit.touchlab.co/) (`co.touchlab:kermit`) | | Formatting | ktfmt via the `com.ncorti.ktfmt.gradle` plugin | -| Testing | JUnit + Turbine | +| Testing | `kotlin.test` + Turbine (JUnit in `:app` only) | | Build | Gradle with a version catalog (`gradle/libs.versions.toml`) | -SDK levels: `minSdk 28`, `targetSdk 37`, `compileSdk 37`. - ## Kotlin Multiplatform -The project **is Kotlin Multiplatform**. The migration ran one module at a time, bottom-up: -`model` → `network` → `repository` → `presenter` → `ui` → `shared` → `shared-compose`. +The project **is Kotlin Multiplatform**. **Every library module is migrated.** The only Android-specific module left is `app`, which stays an Android application module — it is the Android entry point. `web` and `desktop` are its @@ -64,7 +76,7 @@ Supported targets, declared once in the `kmp-library` convention plugin: | Desktop | `jvm` | | iOS | `iosArm64`, `iosSimulatorArm64` | | macOS | `macosArm64` | -| Web | `js`, `wasmJs` (both `browser()` only — see Testing below) | +| Web | `js`, `wasmJs` (both `browser()` only — see Testing KMP modules below) | Rules for library modules: @@ -90,9 +102,6 @@ Rules for library modules: - **Log through Kermit, never `android.util.Log`** — it does not exist in `commonMain`. Take Kermit as an `implementation` dependency; a `Logger` should not appear in a module's public API. -The old Android-only `library.gradle.kts` convention is gone — `kmp-library` and `app` are the -only two module conventions left. - ### Compose and Kotlin Multiplatform Compose here comes from **two** places, and the split is not arbitrary: `org.jetbrains.compose.*` @@ -105,51 +114,20 @@ Compose Multiplatform equivalent. Strings and drawables come from never `commonMain`; Compose Multiplatform owns everything else.** Every version pin carries its reasoning inline in `gradle/libs.versions.toml`, next to the pin. -**Read the `dependency-bump` skill before changing any of them.** The constraints it documents fail +**`.claude/rules/build-scripts.md` loads with the catalog and every build script.** Its constraints fail silently — the 1.12 floor that keeps browser fonts working, material3 being on its own version line, the BOM never reaching `commonMain` — as do the three build requirements a Compose module has (`alias(libs.plugins.compose.multiplatform)`, the `macos` experimental opt-in, and `android { androidResources { enable = true } }` wherever there are `composeResources`). -### Compose Multiplatform resources - -Strings and drawables live in `src/commonMain/composeResources/` (`values/strings.xml`, -`drawable/*.xml`) and are reached through the generated `Res` class, not AGP's `R`: - -```kotlin -import org.jetbrains.compose.resources.stringResource -import io.github.solcott.countries.ui.resources.Res -import io.github.solcott.countries.ui.resources.capital - -stringResource(Res.string.capital, country.capital) -painterResource(Res.drawable.globe_24px) -``` - -`strings.xml` keeps the ordinary Android format, `%1$s` placeholders included. **Vector drawables -must contain no `?attr/…` theme attributes and no `@android:…` references** — CMP's parser cannot -resolve either, and both fail at runtime rather than at build time. Use literal colours -(`#FFFFFFFF`) and let `Icon` supply the real colour from `LocalContentColor`. - -### Apollo and Kotlin Multiplatform - -The Apollo Gradle plugin detects the KMP plugin by itself: it reads operations from -`src/commonMain/graphql/`, attaches the generated code to `commonMain`, and needs no `srcDir` or -output wiring. Per-platform client configuration goes through -`ApolloClient.Builder.platformConfiguration()`, an `expect` extension in `network/src/commonMain` — -add new per-platform concerns to that seam rather than forking the provider. - -Caching is Apollo's normalized cache, configured in `network`; do not add a second layer anywhere -above it. **The web targets use a hand-written SQL.js IndexedDB worker in `network/npm/`, and -`createDefaultWebWorkerDriver()` must not come back** — the reference worker never persists -anything. That worker's `Worker` must not move up into `webMain`: the failure lands in -`compileWebMainKotlinMetadata`, blocks `assemble` for `:network` and everything above it, and -neither web target's own compile task reproduces it. - -**Read the `network-apollo` skill before editing anything under `network/`.** +Compose resources (`Res`, not `R`; vector drawables that fail at runtime) are in +`.claude/rules/compose-resources.md`. Apollo, the normalized cache and the SQL.js worker are in +`.claude/rules/network.md`. Caching is Apollo's normalized cache, configured in `network` — do not +add a second layer anywhere above it. ## Module structure -Thirteen modules, with dependencies flowing strictly downward: +Eleven modules, with dependencies flowing strictly downward: ``` app → Android entry point: Activity, theme, manifest. Nothing else. @@ -216,14 +194,6 @@ There are **two graphs** because of how the platform apps differ: what running a `@Composable` presenter under Molecule requires — see the `apple-app` skill. -All packages live under `io.github.solcott.countries`, with each module using its -own name as the suffix — `…countries.model`, `…countries.network`, -`…countries.repository`, `…countries.presenter`, `…countries.ui`, -`…countries.shared`, `…countries.shared.compose`, `…countries.web`, -`…countries.desktop`, `…countries.apple`. The `app` -module uses the root `io.github.solcott.countries`, which is also the -`applicationId`. Each module's Gradle `namespace` matches its package. - Rules: - **An app module holds no dependency wiring.** `app`, `web` and `desktop` depend on @@ -246,18 +216,8 @@ Rules: nothing but name the one it wants. Material 3 *is* Android's native look, so `MaterialSkin` is the default and Android passes nothing. - The distinction from the seam above is worth keeping: a skin has no `expect`/`actual`, lives in - no platform source set, and can be rendered from Android Studio — which is exactly why the UI is - not forked per platform. Composables read tokens from `LocalAppSkin` rather than taking a dozen - parameters. `AppTheme` also feeds `minInteractiveSize` into - `LocalMinimumInteractiveComponentSize`, which is the one value that takes the whole Material - control set from a 48dp touch target to pointer density. - - Two structural tokens matter more than the cosmetic ones: `contentMaxWidth` and `contentPanel` - are what make `:web` read as a page rather than an app canvas, and no amount of restyling - controls substitutes for them. `:web` also duplicates the page colour in `styles.css`, which - paints before any Kotlin runs — **change one and change the other**, or every cold load flashes - the wrong colour. + The detail — `LocalAppSkin`, `minInteractiveSize`, the structural tokens — is in + `.claude/rules/compose-ui.md`. - A module contributes its own providers with `@ContributesTo(AppScope::class)`, next to the code they construct: `NetworkProviders` in `network`, `CircuitProviders` in `ui`, `LoggingProviders` in `shared`. @@ -280,48 +240,12 @@ Rules: never depend on `ui`. - Only the graph modules (`shared`, `shared-compose`) may depend broadly across the project. -### The three non-Android entry points - -`web`, `desktop` and `apple` each have a skill; the routing table at the top says which. What -follows is only the part you need to know without opening one — every item is something that -**fails with no warning**, so it is repeated here deliberately rather than left to a skill load. - -- **None of the three applies `kmp-library`.** That convention is for libraries: it adds targets - they have no use for and never calls `binaries.executable()`. -- **`:desktop`'s `nativeDistributions { modules(...) }` is load-bearing.** jpackage jlinks a - trimmed JDK and the default set omits what sqlite-jdbc and OkHttp's TLS need. `run` uses the full - JDK, so a missing module surfaces only in an *installed* build, as a crash on the first query. - Test packaging changes with `packageDistributionForCurrentOS`, never `run`. -- **`:apple` — a Kotlin class must not share the exported module's name.** `CountriesKit` is - silently renamed `CountriesKit_` in the generated Swift; the entry point is `CountriesCore` for - that reason. -- **`:apple` — sealed types that cross to Swift are `sealed class`, not `sealed interface`.** - Generic sealed interfaces generate Swift that does not compile, and their members are unreachable - from another module. Nothing warns; the skill has the full table. -- **`:apple` — the deployment floor is iOS 18**, because Swift export's generated coroutine support - uses `Synchronization.Mutex`. No documentation mentions a minimum OS. -- **The Apple app icon is generated, and an empty catalog is invisible.** An `.appiconset` whose - `Contents.json` lists sizes but no `filename` keys builds clean, emits no warning, and produces - an app with no icon. Verify in the built bundle, never from the build log. -- **`:web`'s offline behaviour comes from the service worker, not the Apollo cache.** The - persistent cache only helps once the page is running; without `sw.js` an offline reload never - fetches the bundle at all. - -### Metro graph aggregation — two rules that are easy to get wrong - -Both of these produce confusing errors rather than obvious ones, so they are worth knowing up -front when adding a module or a new graph: - -1. **Contributions are resolved on the compile classpath of the module that declares - `@DependencyGraph`** — Metro generates hints into `metro.hints` and locates them during graph - supertype generation. A module added downstream, in an app module, is too late: its providers - simply will not appear. This is the whole reason `ComposeGraph` lives in `shared-compose` - rather than in `shared` with app modules adding `ui` themselves. - *Symptom:* `[Metro/MissingBinding] No binding found for …`. -2. **Contributing modules must be `api`, not `implementation`, on the graph module.** Contributed - interfaces become *supertypes* of the generated graph, so anything consuming the graph has to - see them too. - *Symptom:* `Cannot access '…NetworkProviders' which is a supertype of 'ComposeGraph'`. +### The non-Android entry points and the graphs + +`web`, `desktop` and `apple` apply no `kmp-library` — it adds targets they have no use for and never +calls `binaries.executable()`. What each one breaks silently is in its path rule (`web.md`, +`desktop.md`, `swift-export.md`, `app-icons.md`); the two Metro aggregation rules, with the errors +they produce, are in `metro-graph.md`. ## Conventions @@ -340,64 +264,27 @@ front when adding a module or a new graph: optional parameter, and applies it to its **root** element — not to something nested inside. Composables that emit nothing are the exception: `AppTheme` (a wrapper) and `DataError.toUserMessage()` (returns a `String`) correctly have none. - `detekt/detekt.yml` already enables `ModifierMissing` and `ModifierNotUsedAtRoot`, but detekt - is currently only wired up for `build-logic`, so nothing enforces this in the modules yet. -- **Every composable that emits UI has a `@Preview`** — see the `compose-previews` skill for the - import, the two project multipreview annotations, and the fixtures to reuse. Preview the states - that are easy to break, not just the happy path: loading, loaded, error, empty. + detekt enforces this — the `detekt` convention applies `config/detekt/detekt.yml`, with + `ModifierMissing` and `ModifierNotUsedAtRoot` active, to every module, and CI's `./gradlew build` + runs it. +- **Every composable that emits UI has a `@Preview`.** Nothing enforces this one; the + `compose-conventions` subagent audits it. The import, the two project multipreviews and the + fixtures are in `.claude/rules/compose-ui.md`. Preview the states that are easy to break, not just + the happy path: loading, loaded, error, empty. ## Build setup -`settings.gradle.kts` applies the `org.gradle.toolchains.foojay-resolver-convention` -plugin so Gradle can auto-provision missing JDKs. - -The daemon JVM is pinned in the root `build.gradle.kts`: - -```kotlin -tasks.named("updateDaemonJvm") { - languageVersion = JavaLanguageVersion.of(25) - vendor.set(JvmVendorSpec.AMAZON) -} -``` - -Run `./gradlew updateDaemonJvm` to regenerate `gradle/gradle-daemon-jvm.properties` -after changing that block. The generated properties file is committed. - -Note that the daemon JVM is independent of what the modules compile against: -**all modules target Java 17** (`compileOptions` / Kotlin `jvmTarget`). - -That 17 has one source, `Versions` in `build-logic`, and **a module build script can import it** — -`import io.github.solcott.countries.build.Versions`. It is not restricted to the convention plugins, -because `Versions.class` rides in the same `build-logic.jar` as the plugin descriptors, so applying -any convention from that build (every non-convention module applies at least `formatting`) puts it -on the script's own classpath. `:desktop` and `:apple` use it that way. - -So a one-off module needing a shared version **imports it rather than earning a convention** — which -is why `kmp-library` and `app` are still the only two. Note `build-logic/build.gradle.kts`'s own -`jvmToolchain(25)` is a different fact: that is the JVM the convention plugins themselves compile -against, matching the daemon, not the modules' target. - -AGP 9 has built-in Kotlin support, so Android modules must **not** apply -`org.jetbrains.kotlin.android` — AGP fails the build if they do. The root buildscript -classpath forces the KGP and Compose compiler plugin versions Metro needs; modules apply -the remaining Kotlin-family plugins (`plugin.compose`, `plugin.parcelize`) by id with no -version, picking up those classpath versions. +The daemon JVM is pinned in the root `build.gradle.kts`. After changing that block, run +`./gradlew updateDaemonJvm` and commit the regenerated `gradle/gradle-daemon-jvm.properties`. The +daemon JVM is independent of what the modules compile against: **all modules target Java 17**. Metro's Circuit codegen is switched on by `metro.enableCircuitCodegen=true` in `gradle.properties`, which generates the `Presenter.Factory` / `Ui.Factory` multibindings from `@CircuitInject`. No separate Circuit KSP processor is needed. -`settings.gradle.kts` uses `RepositoriesMode.PREFER_SETTINGS`, not `FAIL_ON_PROJECT_REPOS`. -The Kotlin plugin unconditionally registers project-level repositories for the js/wasmJs -toolchain downloads, which `FAIL_ON_PROJECT_REPOS` rejects at registration time. Those -downloads (Node, Yarn, Binaryen) are declared as content-filtered `ivy` repositories in the -settings `repositories` block instead, so every dependency still resolves from there. - -`kotlin-js-store/` holds **two** committed lockfiles, because js and wasmJs have separate npm -stores: `yarn.lock` for js and `wasm/yarn.lock` for wasmJs. Regenerate them with -`./gradlew kotlinUpgradeYarnLock` and `./gradlew kotlinWasmUpgradeYarnLock` rather than editing -them. A build that touches only one store fails with "Lock file was changed" naming the task it -needs, so it is easy to fix one and forget the other. +Everything else about the build scripts — importing `Versions` into a module script, AGP 9's +built-in Kotlin, plugins applied by id, `RepositoriesMode.PREFER_SETTINGS`, the two npm lockfiles — +is in `.claude/rules/build-scripts.md`, which loads with any build script. ## Commands @@ -409,58 +296,11 @@ needs, so it is easy to fix one and forget the other. ./gradlew :model:assemble # build a KMP module for every target ./gradlew :model:allTests # run a KMP module's tests on every target - -# Browser app — serves on http://localhost:8080 -./gradlew :web:wasmJsBrowserDevelopmentRun -./gradlew :web:jsBrowserDevelopmentRun -./gradlew :web:wasmJsBrowserDistribution # → web/build/dist/wasmJs/productionExecutable -./gradlew :web:jsBrowserDistribution # → web/build/dist/js/productionExecutable - -# 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 - -# Apple bridge — the Kotlin half of the SwiftUI app -./gradlew :apple:macosArm64Test :apple:iosSimulatorArm64Test - -# Inspect the generated Swift without going through Xcode. Swift export registers its tasks only -# when Xcode's environment variables are present, hence the prefix. Output lands in -# apple/build/SwiftExport//Debug/files/. -CONFIGURATION=Debug SDK_NAME=macosx ARCHS=arm64 TARGET_BUILD_DIR=/tmp/se \ -FRAMEWORKS_FOLDER_PATH=Frameworks ./gradlew :apple:macosArm64DebugSwiftExport - -# iOS / iPadOS / macOS app. Xcode runs the Gradle export itself, so open the project and hit run -# rather than building anything first. -open iosApp/Countries.xcodeproj -xcodebuild -project iosApp/Countries.xcodeproj -scheme Countries \ - -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build -xcodebuild -project iosApp/Countries.xcodeproj -scheme Countries \ - -destination 'platform=macOS,arch=arm64' build - -# Unit tests and UI tests together. Run both destinations — several UI tests are device-shape -# specific and skip themselves on the shape they do not describe. -xcodebuild test -project iosApp/Countries.xcodeproj -scheme Countries \ - -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -xcodebuild test -project iosApp/Countries.xcodeproj -scheme Countries \ - -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.4' ``` -**Tests run on iOS simulators only; the macOS destination is build-and-run.** `xcodebuild test` for -macOS fails with "Signing for CountriesUITests requires a development team" — Xcode builds every -testable in the scheme regardless of the target's `SUPPORTED_PLATFORMS` or of `-only-testing`, and a -macOS UI-test runner cannot be ad-hoc signed. Nothing is lost: the unit tests are pure functions -with no platform-specific behaviour, and they run on the simulator. - -Both desktop packaging tasks produce a build for the **host** OS only — see -the `desktop-app` skill. +Platform commands — the browser dev server and distributions, desktop packaging and hot reload, +the Swift export and `xcodebuild` invocations — are in the matching rule (`web.md`, `desktop.md`, +`swift-export.md`). `ktfmtCheck` at the root does not cover `build-logic` — that is a separate included build. Run it from inside `build-logic/` to check the convention plugins. @@ -494,52 +334,7 @@ are `@ExperimentalKermitApi`, so test classes using them need ### Testing KMP modules -Tests go in `src/commonTest/kotlin` and run on **every** target — `allTests` drives six runners: -`jvmTest`, `testAndroidHostTest`, `jsBrowserTest`, `wasmJsBrowserTest`, `macosArm64Test` and -`iosSimulatorArm64Test`. `kotlin("test")` is wired into `commonTest` by the convention plugin; -add `libs.kotlinx.coroutines.test` per module if you need `runTest`. - -**The web targets are `browser()` only — there is deliberately no `nodejs()`.** The web targets -exist for a browser app, and Node could not run the whole suite anyway: Compose/Molecule's frame -clock lives in Molecule's `browserMain` source set, so under Node recomposition never advances and -a presenter test awaiting a second emission fails. Adding `nodejs()` back to `kmp-library` would -reintroduce two runners that cannot pass. - -Consequences worth knowing before you add the first test to a module: - -- The browser runners need **Chrome** installed; the Apple runners need **Xcode**, and - `iosSimulatorArm64Test` boots a simulator. -- Use camelCase test names, not backticked names with spaces — that is the portable choice - across the JS and native runners. -- Adding tests can change `kotlin-js-store/yarn.lock`, because the JS test link pulls in - packages the main compilation did not. If a build fails with "Lock file was changed", run - `./gradlew kotlinUpgradeYarnLock` and commit the result. -- JUnit is JVM-only. Do not add `testImplementation(libs.junit)` to a migrated module; use - `kotlin.test` assertions instead. -- **`SnapshotStateList.equals` is structural on JVM/Android but identity-based on native and - Kotlin/JS.** Asserting `assertEquals(listOf(x), someSnapshotStateList)` passes on JVM and fails - everywhere else. Call `.toList()` first. Expect other JVM-only accidents like this to surface - the first time a module's tests run cross-platform. -- **`testAndroidHostTest` links the android.jar stubs**, so anything backed by a real framework - class is inert there. `SavedState` is the live example: it is an `android.os.Bundle`, whose - `put`/`get` are no-ops on that runner, so a value "saves" into a Bundle that kept nothing and - restores as null. Nothing warns — you get a bare `expected: but was:` on one runner out - of six. `ComposeGraphSaverRoundTripTest` sits in `:shared-compose`'s `jvmTest` for this reason; - there is no intermediate source set for "every target but the Android host". -- **The first test in a Compose module needs `js { binaries.executable() }`, `wasmJs { … }` and the - Compose Multiplatform plugin** — even if the module declares no Compose dependency of its own and - only reaches one transitively. Without them the browser test bundle cannot load skiko, and the - task reports *"did not discover any tests"* rather than naming the cause. `:shared-compose` is a - module that needed all three the moment it gained a test. -- **A heavy browser test bundle blows karma's 30s `browserNoActivityTimeout` on CI**, and reports - the *same* *"did not discover any tests"* — the browser disconnects with "no message in 30000 ms" - before the first test reports, having spent the whole window just downloading skiko. It passes - locally, where Chrome is fast, and fails only on a CI runner. `:shared-compose:jsBrowserTest` is - the live case (it drags in `:ui`, so its bundle is ~15 MB); the timeout is raised in - `shared-compose/karma.config.d/`. `:presenter` and `:ui` load under the default today — add the - same snippet if they start disconnecting. -- **The first *native* test binary to link the whole graph needs `linkerOpts("-lsqlite3")`.** The - Apollo plugin adds it to `:network`'s own targets and the Apple app gets it from Xcode's - `OTHER_LDFLAGS`, but a Kotlin/Native klib records no linker options, so a downstream test - executable inherits neither and fails at link with a wall of undefined `_sqlite3_*` symbols. See - `shared-compose/build.gradle.kts`. +Tests go in `src/commonTest/kotlin` and run on **every** target — `allTests` drives six runners. +The web targets are `browser()` only, deliberately. The portability traps (camelCase names, +`SnapshotStateList`, the Android host runner's stub `Bundle`, what the first test in a Compose +module needs) are in `.claude/rules/kmp-tests.md`, which loads with any test source set.