This file provides shared guidance to Claude Code and Codex when working with code in this repository.
# First-time setup (installs ktfmt + pre-commit hook)
./scripts/setup-hooks.sh
# Android
./gradlew :client:androidApp:installDebug
# Desktop (JVM)
./gradlew :client:composeApp:run
# Server
./gradlew :server:run
# Lint / format
./gradlew ktfmtFormat # auto-format
./gradlew ktfmtCheck # check only
# Tests (all)
./gradlew test
# Tests (single module)
./gradlew :client:grocery:core:impl:test
# Snapshot tests (record then verify)
./gradlew :client:ui:screenshot-test:updateDebugScreenshotTest
./gradlew :client:ui:screenshot-test:validateDebugScreenshotTestFor iOS, open /iosApp in Xcode or use the IDE run configuration.
- Never
git pushwithout an explicit request from the user in the current message. "Commit it" / "save this" / "go ahead" authorize a commit but not a push. The push is always a separate, user-requested action. - Never bundle
git pushinto the same Bash invocation asgit add/git commit. Run the commit on its own and stop. The user will issue a separate "push" instruction when they're ready. - When opening a PR, split work into multiple commits by concern type so reviewers can step through them. A typical order: refactors/renames first (no behavior change), then the feature or fix, then tests, then docs / strings / generated artifacts. One concern per commit, each with a conventional-commit subject (
refactor(recipe): …,feat(recipe): …,test(recipe): …,docs: …). If a change is genuinely one concern, one commit is fine — don't manufacture splits. - For feature work, split commits along the testing layers (see Architecture below):
- One commit per BLoC, bundling that BLoC's unit tests with it. Each BLoC (interface + impl + ViewModel) ships in the same commit as its
impl/src/commonTestunit tests. If a PR touches several BLoCs, give each its own commit. - Snapshots in their own commit. The
<Feature>Previews.ktpreviews, thescreenshot-test@PreviewTestwrappers, and the recorded reference PNGs go together in a single dedicated commit, separate from the BLoC logic. - Automation tests last. Robot UI tests and the
impl-robotsmodules that back them (the<Feature>Robot.ktclasses and therunRootBlocTest { … }flow tests) come in the final commit.
- One commit per BLoC, bundling that BLoC's unit tests with it. Each BLoC (interface + impl + ViewModel) ships in the same commit as its
This is a Kotlin Multiplatform app targeting Android, iOS, Desktop (JVM), Web (WASM), and a Ktor server. All new code must be Kotlin.
Every feature is split into three module types:
public— API contracts (interfaces, models, UI screens). May depend on otherpublicmodules only.impl— Production implementation. Depends onpublicmodules only. Every newimplmodule must be added toclient/composeApp/build.gradle.ktsundercommonMainto register it in the DI graph.testing— Fake/stub implementations for use in other modules' tests.
The plusLibrary extension in each module's build.gradle.kts controls convention plugin features:
enableDi = true— sets up Metro (kotlin-inject) dependency injectionenableTesting = true— adds test dependencies (mokkery, turbine, kotest, coroutines-test)enableDatabaseTesting = true— addsclient/database/testing(in-memory SQLDelight) to test dependencies and links sqlite3 for iOS. Use this when tests need a real database viacreateTestDatabase(). RequiresenableTesting = true.uiTest = true— addscompose.uiTestas anapidependency oncommonMain. Used byimpl-robotsmodules that expose reusable Compose UI test robots. Requires thecomposeplugin to also be applied.
Every screen is a BLoC. The pattern is:
- Interface (
GroceryListBloc) — declaresstate: StateFlow<Model>, click handlers, nestedModel,Output, and aFactoryfun interface. - Impl (
GroceryListBlocImpl) — annotated with@Inject+@ContributesAssistedFactory(scope = AppScope::class, assistedFactory = ...). DelegatesBlocContext by context. Gets a ViewModel viainstanceKeeper.getViewModel { ... }. - ViewModel — annotated
@Inject. ExtendsViewModel(@Main mainContext). Usesscope(coroutine scope on main thread) for async work. HoldsMutableStateFlow<State>.
Navigation BLoCs expose routerState: Value<ChildStack<*, Child>>, use StackNavigation<Configuration> with childStack(), and use serializable Configuration sealed classes. The Child sealed class exposes an abstract val bloc: ComposeScreen, and each variant overrides it with its concrete bloc (data class Detail(override val bloc: RecipeDetailBloc) : Child()). This lets the navigation screen render any child uniformly with child.instance.bloc.Content() — no when over child types.
See docs/architecture.md for full annotated examples of both patterns.
- Offline-first: All data stored locally via SQLDelight (
client/database/coremodule). - Remote: Supabase (Kotlin Multiplatform SDK) for sync.
- Repositories mediate between local cache and remote source.
- All UI is Compose Multiplatform, shared across all client targets.
- Screen composables use the
Screensuffix (e.g.,RecipeListScreen.kt) and live in the feature'spublicmodule. - Reusable components live in
client/ui/public, prefixed withPlus(e.g.,PlusHeaderContainer). - Prefer a shared
Plus*component over a raw Material composable when one exists. For example, usePlusDialoginstead of MaterialAlertDialog. Checkclient/ui/public/.../componentsbefore reaching for a Material primitive. - Spacing/sizing dimensions should come from the theme (
ChefMateTheme.dimens), not hardcoded.dpliterals. Use the closestAppDimensionstoken (paddingExtraSmall4,paddingSmall8,paddingNormal16,paddingLarge24,paddingExtraLarge32,rowHeight56,fabClearance88). Only fall back to a raw.dpliteral when the value is genuinely off-spec (no matching token); if an off-spec value recurs, add a token toAppDimensionsinstead.
Pick the layer by scope — do not hand-roll a SnackbarHostState + LaunchedEffect per screen.
-
App-wide toast (default):
ToastService(client/toast/public). InjectToastServiceinto a BLoC/ViewModel and calltoastService.show(message, actionLabel?, duration?, onAction?). A single global host (ToastScaffold, wired once inApp.kt) renders it over every screen — never add your own host for these. The service is anAppScopesingleton, so it's constructor-injectable anywhere; for a composable with no BLoC, useLocalToastService.current.show(...). For an action button, passactionLabel+onAction— but route navigation through app-scoped output, since the callback is held only for the toast's lifetime (don't capture a short-lived screen object). Worked example:RecipeDetailBlocImpl(added-to-grocery toast with a "View" action). -
Screen-local snackbar (only when it must be scoped to one screen):
SnackbarQueue+PlusSnackbarHost. Hold aSnackbarQueuein the BLoCModel,enqueue(...)onto it from the ViewModel, exposeonSnackbarShown(id), and dropPlusSnackbarHost(state.snackbars, bloc::onSnackbarShown)into the screen. The queue's monotonic ids make dequeue correct under rapid messages. Worked example:MealPlanScreen. -
Raw Material
SnackbarHostonly for a one-off, screen-private message that never needs queueing (e.g. "copied to clipboard").
Keeping FABs & bottom toolbars clear of a shown snackbar. A bottom-aligned element can be covered by a snackbar. The shared components handle this for you: PlusFloatingActionButton (FABs) and PlusToolbar (the bottom horizontal floating toolbar, wrapping Material's HorizontalFloatingToolbar) both ride up automatically. Prefer them over the raw Material primitives for bottom-aligned controls. For a raw FAB, a custom FAB stack, or a hand-rolled bottom bar, opt in by adding Modifier.padding(bottom = LocalSnackbarInset.current) (from client/ui/public) to its bottom-aligned root — it animates up while a snackbar is visible and back when it dismisses. Top toolbars (PlusHeader) are unaffected and need nothing. Worked example: RecipeDetailScreen's PlusToolbar lifts above the added-to-grocery toast.
- Navigation (root and per-feature) renders a Decompose
ChildStackwithChildren+predictiveBackAnimation. Use the sharedbackAnimationhelper inclient/ui/public/.../BackAnimation.kt(apredictiveBackAnimationwith aslidefallback).RootScreeninlinespredictiveBackAnimationdirectly because it varies the fallback by child type (modal screens slide vertically). Predictive back is the priority — the system back gesture/animation must keep working across the whole stack. - Never wrap root navigation in
SharedTransitionLayout+AnimatedContent. Doing so replaces Decompose'sChildrenand disables predictive back — that combination was removed for exactly this reason. - Shared-element transitions are only for self-contained, in-screen morphs that own their own navigation and do not cross the root stack. Two examples exist: the recipe-detail full-screen image (
RecipeDetailScreenwraps its ownSharedTransitionLayoutaround an innerAnimatedContent) and the browser address bar (BrowserRootScreen). Each provides its scope locally, so predictive back elsewhere is unaffected. - To add a shared element: wrap the owning screen in a local
SharedTransitionLayout, provideLocalSharedTransitionScopefrom it, and use thesharedElementByhelpers + theLocal*VisibilityScopecomposition locals inclient/ui/public/.../SharedTransitionScopes.kt. Do not hoist the scope to the root.
Use TextData (sealed class) for all display strings to separate domain from UI:
FixedString("raw string")— for API data or previewsResourceString(Res.string.key)— for localized resourcesPhraseModel(Res.string.key, "arg" to FixedString("value"))— for formatted strings
Call .localized() inside Composables to resolve to a String. Expose text module with api (not implementation) in public modules.
Add strings at client/<module>/src/commonMain/composeResources/values/strings.xml. Rebuild to generate accessors. Import the compose plugin and compose.components.resources dependency in the module's build.gradle.kts.
Apostrophes and quotes. Use the typographic curly glyphs (’, ‘, “, ”) directly in strings.xml — not ASCII straight quotes (', "). The codebase convention is curly throughout (e.g., auth_switch_to_sign_up: "Don’t have an account?"), and curly glyphs don't need the XML backslash escape that ASCII apostrophes require (You\'re → You’re).
dev.zacsweers.metrois the DI framework (compiler plugin).AppScopeis the standard scope.@ContributesAssistedFactory(scope = AppScope::class, assistedFactory = FooBloc.Factory::class)fromcom.plusmobileapps.metro-extensionsis placed alongside@AssistedInjecton each BLoC impl. A KSP processor generates the Metro@AssistedFactoryand a@ContributesBinding/@Origin-tagged bridge to the publicFactoryinterface — no manual binding modules needed.- KSP + metro-extensions are wired automatically by
MetroConventionPluginfor any module withplusLibrary { enableDi = true }.
Rule: any change that touches a screen, composable, or shared UI component must add or update a snapshot test in the same commit/PR. If a <Feature>ScreenshotTest.kt already covers the affected preview, refresh the reference with ./gradlew :client:ui:screenshot-test:updateDebugScreenshotTest and visually inspect the diff before committing. If no snapshot exists for the screen yet, add one following the pattern below — including dark-mode and any meaningful state variants (empty, loading, error). CI only catches regressions of existing references, so missing coverage on new UI won't fail the build.
Compose preview screenshot tests live in client/ui/screenshot-test — a plain Android library module (not KMP) that uses Google's com.android.compose.screenshot plugin. KMP modules can't host the screenshotTest source set with com.android.library, so all snapshot tests are centralized here and depend on the relevant feature public modules.
Pattern:
- In the feature's
publicmodule, add a<Feature>Previews.ktfile incommonMainwith:- public
previewXxxBlocvals — fake Bloc implementations (object : XxxBloc { ... }returningMutableStateFlow(model)andUnitfor handlers). Public visibility is required soscreenshot-testcan reuse them. internal @Preview @Composablefunctions for the IDE preview pane.
- public
- In
client/ui/screenshot-test/build.gradle.kts, addimplementation(project(":client:<feature>:public")). - In
client/ui/screenshot-test/src/screenshotTest/kotlin/.../<Feature>ScreenshotTest.kt, write@PreviewTest @Preview @Composablewrappers that import the public preview Blocs and call the screen. - Record references:
./gradlew :client:ui:screenshot-test:updateDebugScreenshotTest. Visually inspect the PNGs underclient/ui/screenshot-test/src/screenshotTestDebug/reference/before committing. - CI runs
validateDebugScreenshotTestvia thescreenshot-testsjob in.github/workflows/tests.yml. Diff reports upload as artifacts on failure.
Reusable fixtures: Recipe.Sample is a populated companion val on Recipe (next to Recipe.Empty) for any preview that needs realistic recipe data. Add similar <Type>.Sample companions on shared data classes rather than redefining them per feature.
Caveats: Plugin is Android-only (no JVM/iOS targets) and currently alpha (com.android.compose.screenshot:0.0.1-alpha14). @PreviewTest annotations only go in screenshot-test — never in production modules, since the annotation lives in a test-only artifact.
See client/cook/public/.../CookModePreviews.kt and client/ui/screenshot-test/.../CookModeScreenshotTest.kt for a worked example covering stacked, split, loading, empty, and dark variants.
Rule: every new feature must ship with a multiplatform Compose UI test using the impl-robots pattern. Robots wrap ComposeUiTest with domain-level vocabulary so test cases read as user flows, not semantics-tree lookups. They run on all client targets (Android, iOS, Desktop, Web) because they live in commonMain.
Pattern:
- For a new feature
client/<feature>/<sub>/impl, create a sibling moduleclient/<feature>/<sub>/impl-robotswith abuild.gradle.ktsthat applieskmpLibrary+compose, setsplusLibrary { uiTest = true }(which wires thecompose.uiTestapidependency), and addsimplementation(projects.client.<feature>.<sub>.public). Seeclient/recipe/list/impl-robots/build.gradle.ktsfor the minimal shape. - Add
<Feature>Robot.ktundersrc/commonMain/kotlin/.../<feature>/robots/. TakeComposeUiTestin the constructor. Scope every node lookup to a descendant of the screen's root test tag (hasAnyAncestor(hasTestTag(<Feature>TestTags.SCREEN))) so titles rendered on other screens don't satisfy matchers. Provide a factory extension (e.g.fun ComposeUiTest.recipeList() = …). Return<Feature>Robotfrom every action method for chaining. - Add a test in
client/composeApp/src/commonTest/...(or the feature's own test module) that runs throughrunRootBlocTest { … }and composes one or more robots into a user flow. Reference example:client/composeApp/src/commonTest/.../RootNavigationUiTest.kt. - For async UI states, expose an
awaitDisplayed()on the robot usingwaitUntilExactlyOneExists— seeclient/recipe/core/impl-robots/.../RecipeDetailRobot.kt.
Robots are not unit tests of a BLoC — those still live in impl/src/commonTest. Robots cover the rendered screen and cross-screen navigation flows.
| Concern | Path |
|---|---|
| App entry (common) | client/composeApp/src/commonMain/.../App.kt |
| DI component | client/composeApp/src/commonMain/.../ApplicationComponent.kt |
| Database schemas | client/database/core/src/commonMain/sqldelight/ |
| Root navigation | client/root/ |
| Shared utilities | client/shared/ |
| Reusable UI | client/ui/public/ |
| TextData | client/text/public/ |
| Snapshot tests | client/ui/screenshot-test/ |
| Robot UI tests | client/<feature>/impl-robots/ |
| Architecture docs | docs/architecture.md |