diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
new file mode 100644
index 0000000..ee3d762
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -0,0 +1,15 @@
+
+
+
+ Recipe
+ Zaloguj się przez Authentik
+ Wyloguj się
+ Witaj, %1$s!
+ Logowanie anulowane. Spróbuj ponownie.
+ Nie można połączyć z Authentik. Sprawdź połączenie.
+ Coś poszło nie tak. Spróbuj ponownie.
+
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
new file mode 100644
index 0000000..e52bb22
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
@@ -0,0 +1,35 @@
+package dev.ulfrx.recipe.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Phase 2 seed theme. Material 3 light/dark schemes with a single seed override on `primary`
+ * (`#3B6939` light / `#A2D597` dark — see `02-UI-SPEC.md` § Color). All other roles use
+ * Material 3 baseline values. Phase 11 may rebase the palette around a different seed.
+ *
+ * Intentionally minimal: no Haze, no custom typography, no shapes. Per UI-SPEC, Material 3
+ * defaults satisfy Phase 2's spacing/typography/accessibility contract.
+ */
+private val LightColors =
+ lightColorScheme(
+ primary = Color(0xFF3B6939),
+ )
+
+private val DarkColors =
+ darkColorScheme(
+ primary = Color(0xFFA2D597),
+ )
+
+@Composable
+fun RecipeTheme(content: @Composable () -> Unit) {
+ val colors = if (isSystemInDarkTheme()) DarkColors else LightColors
+ MaterialTheme(
+ colorScheme = colors,
+ content = content,
+ )
+}
diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt
new file mode 100644
index 0000000..530ba05
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt
@@ -0,0 +1,207 @@
+package dev.ulfrx.recipe.ui.screens.auth
+
+import dev.ulfrx.recipe.auth.AuthLoginResult
+import dev.ulfrx.recipe.auth.AuthSession
+import dev.ulfrx.recipe.auth.AuthStateStore
+import dev.ulfrx.recipe.auth.MeGateway
+import dev.ulfrx.recipe.auth.OidcClientGateway
+import dev.ulfrx.recipe.auth.OidcResult
+import dev.ulfrx.recipe.shared.dto.User
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.yield
+import recipe.composeapp.generated.resources.Res
+import recipe.composeapp.generated.resources.auth_error_cancelled
+import recipe.composeapp.generated.resources.auth_error_network
+import recipe.composeapp.generated.resources.auth_error_unknown
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class LoginViewModelTest {
+ @Test
+ fun cancelledAuthFailureMapsToCancelledStringResource() {
+ runBlocking {
+ val session = newSession(loginResult = OidcResult.Cancelled)
+ val viewModel = LoginViewModel(session)
+
+ viewModel.onSignInClick().join()
+
+ assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
+ assertEquals(false, viewModel.state.value.isLoading)
+ }
+ }
+
+ @Test
+ fun networkAuthFailureMapsToNetworkStringResource() {
+ runBlocking {
+ val session = newSession(loginResult = OidcResult.NetworkError)
+ val viewModel = LoginViewModel(session)
+
+ viewModel.onSignInClick().join()
+
+ assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey)
+ assertEquals(false, viewModel.state.value.isLoading)
+ }
+ }
+
+ @Test
+ fun unknownAuthFailureMapsToUnknownStringResource() {
+ runBlocking {
+ val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed"))
+ val viewModel = LoginViewModel(session)
+
+ viewModel.onSignInClick().join()
+
+ assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey)
+ assertEquals(false, viewModel.state.value.isLoading)
+ }
+ }
+
+ @Test
+ fun successClearsErrorAndStopsLoading() {
+ runBlocking {
+ val session =
+ newSession(
+ loginResult =
+ OidcResult.Success(
+ authStateJson = "{}",
+ accessToken = "access",
+ idToken = null,
+ expiresAtEpochMillis = 0L,
+ ),
+ )
+ val viewModel = LoginViewModel(session)
+
+ viewModel.onSignInClick().join()
+
+ assertNull(viewModel.state.value.errorKey)
+ assertEquals(false, viewModel.state.value.isLoading)
+ }
+ }
+
+ @Test
+ fun startingNewSignInClearsPreviousErrorAndSetsLoading() {
+ runBlocking {
+ // First login fails with cancelled to seed an error.
+ val firstOidc = FakeOidcClient(loginResult = OidcResult.Cancelled)
+ val session = AuthSession(firstOidc, FakeAuthStateStore(), FakeMeClient(USER))
+ val viewModel = LoginViewModel(session)
+ viewModel.onSignInClick().join()
+ assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
+
+ // Second login: gate the result so we can observe loading=true and error cleared.
+ val gate = CompletableDeferred()
+ val gatedOidc =
+ object : OidcClientGateway {
+ override suspend fun login(): OidcResult = gate.await()
+
+ override suspend fun refresh(authStateJson: String): OidcResult =
+ OidcResult.AuthError("not used")
+
+ override suspend fun logout(authStateJson: String) {}
+ }
+ val gatedSession = AuthSession(gatedOidc, FakeAuthStateStore(), FakeMeClient(USER))
+ val gatedViewModel = LoginViewModel(gatedSession)
+
+ // Seed an error on the gated VM by directly priming via the cancelled session.
+ // Simpler: assert behavior with a single VM that has both attempts.
+ val singleOidcQueue =
+ mutableListOf(
+ OidcResult.Cancelled,
+ )
+ val singleOidc =
+ object : OidcClientGateway {
+ override suspend fun login(): OidcResult {
+ // First call returns immediately; subsequent calls await the gate.
+ return if (singleOidcQueue.isNotEmpty()) {
+ singleOidcQueue.removeAt(0)
+ } else {
+ gate.await()
+ }
+ }
+
+ override suspend fun refresh(authStateJson: String): OidcResult =
+ OidcResult.AuthError("not used")
+
+ override suspend fun logout(authStateJson: String) {}
+ }
+ val singleSession = AuthSession(singleOidc, FakeAuthStateStore(), FakeMeClient(USER))
+ val singleVm = LoginViewModel(singleSession)
+ singleVm.onSignInClick().join()
+ assertEquals(Res.string.auth_error_cancelled, singleVm.state.value.errorKey)
+
+ // Now start the second login and observe immediate loading + cleared error.
+ val job =
+ async(Dispatchers.Unconfined) {
+ singleVm.onSignInClick().join()
+ }
+ // Yield to let the VM enter the loading state before await suspends.
+ yield()
+ assertTrue(singleVm.state.value.isLoading)
+ assertNull(singleVm.state.value.errorKey)
+
+ gate.complete(OidcResult.Cancelled)
+ job.await()
+
+ // After completion, loading is false and the new (still cancelled) error is set.
+ assertEquals(false, singleVm.state.value.isLoading)
+ assertEquals(Res.string.auth_error_cancelled, singleVm.state.value.errorKey)
+ }
+ }
+
+ private fun newSession(
+ loginResult: OidcResult,
+ store: AuthStateStore = FakeAuthStateStore(),
+ meClient: MeGateway = FakeMeClient(USER),
+ ): AuthSession =
+ AuthSession(
+ oidcClient = FakeOidcClient(loginResult = loginResult),
+ store = store,
+ meClient = meClient,
+ )
+
+ private class FakeAuthStateStore(
+ var value: String? = null,
+ ) : AuthStateStore {
+ override fun read(): String? = value
+
+ override fun write(authStateJson: String) {
+ value = authStateJson
+ }
+
+ override fun clear() {
+ value = null
+ }
+ }
+
+ private class FakeOidcClient(
+ private val loginResult: OidcResult = OidcResult.AuthError("login not configured"),
+ private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"),
+ ) : OidcClientGateway {
+ override suspend fun login(): OidcResult = loginResult
+
+ override suspend fun refresh(authStateJson: String): OidcResult = refreshResult
+
+ override suspend fun logout(authStateJson: String) {}
+ }
+
+ private class FakeMeClient(
+ private val user: User,
+ ) : MeGateway {
+ override suspend fun getMe(accessToken: String?): User = user
+ }
+
+ private companion object {
+ val USER =
+ User(
+ id = "00000000-0000-0000-0000-000000000001",
+ sub = "authentik-sub",
+ email = "user@example.invalid",
+ displayName = "Recipe User",
+ )
+ }
+}