UI-SPEC verified — all 6 dimensions PASS. No flags. Frontmatter status flipped from draft to approved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24 KiB
phase, slug, status, shadcn_initialized, preset, created, reviewed_at
| phase | slug | status | shadcn_initialized | preset | created | reviewed_at |
|---|---|---|---|---|---|---|
| 2 | authentication-foundation | approved | false | none | 2026-04-27 | 2026-04-27 |
Phase 2 — UI Design Contract
Visual and interaction contract for the Authentication Foundation phase. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
Stack note: This is a Kotlin Multiplatform / Compose Multiplatform app, not shadcn/web. The template's "shadcn" framing has been adapted for Material 3 on CMP. All values below are expressed in
dp(Compose) and Material 3Type/ColorSchemeroles.Phase boundary reminder: Phase 2 ships SCAFFOLD UI quality — three composables (
SplashScreen,LoginScreen,PostLoginPlaceholderScreen) plus the auth gate inApp(). The Liquid-Glass visual language and Haze blur land in Phase 10. The polished Polish copy + display font live in Phase 11. Tokens locked here are the SEED that later phases extend, not retroactively rewrite.
Design System
| Property | Value |
|---|---|
| Tool | Compose Multiplatform Material 3 |
| Preset | not applicable |
| Component library | androidx.compose.material3 (CMP port via compose-multiplatform 1.7+) |
| Icon library | none used in Phase 2 (no icons on Splash / Login / PostLoginPlaceholder); androidx.compose.material.icons available but deferred to Phase 5+ |
| Font | system default — FontFamily.Default (Compose Multiplatform resolves to SF on iOS, Roboto on Android, system default on JVM/Wasm). Reserved for Phase 11: display font selection + custom FontFamily in Compose Resources. |
Component sourcing: Material 3 stdlib only (Surface, Text, Button, OutlinedButton, CircularProgressIndicator, Column, Spacer, Box). No third-party UI components, no Haze, no custom blur. Haze blur is explicitly deferred to Phase 10 per CLAUDE.md non-negotiable #10 ("Haze on chrome only, never over fast-scrolling content").
Spacing Scale
Declared values (all multiples of 4, expressed in Compose dp):
| Token | Value | Phase 2 Usage |
|---|---|---|
| xs | 4.dp | Reserved for later phases (icon gaps, fine adjustments) |
| sm | 8.dp | Vertical gap between welcome text and "Wyloguj się" button on PostLogin; vertical gap between "Recipe" wordmark and progress indicator on Splash |
| md | 16.dp | Default vertical gap between button and inline error text on LoginScreen; horizontal screen-edge padding on all three screens |
| lg | 24.dp | Vertical gap between app-name display and primary button on LoginScreen; vertical gap between welcome text block and logout button on PostLoginPlaceholder |
| xl | 32.dp | Reserved for later phases (planner/calendar layouts) |
| 2xl | 48.dp | Vertical breathing room between top-most centered content cluster and its surrounding empty space (visual centering target on LoginScreen and Splash) |
| 3xl | 64.dp | Reserved for later phases (page-level section breaks in catalog / planner) |
Touch target floor: All interactive controls (buttons) honor Material 3 minimum touch target of 48.dp height. Material 3's Button defaults satisfy this; do not shrink.
Safe-area insets: All three screens wrap their root in Modifier.safeContentPadding() (already established by Phase 1's App.kt pattern). This keeps content clear of the iOS notch/home indicator and Android system bars without introducing platform-specific code in commonMain.
Exceptions: none. The full xs..3xl scale is declared for forward-compat with Phases 3+; tokens marked "Reserved for later phases" are spec'd here so the planner/executor draws from one canonical scale instead of inventing per-phase increments.
Typography
Material 3 Typography roles. Phase 2 uses four roles; the rest of the M3 scale is implicitly available for later phases. Phase 11 may swap FontFamily but the role-to-element mapping below is locked.
| Role | M3 Token | Size | Weight | Line Height | Phase 2 Element |
|---|---|---|---|---|---|
| Display | displaySmall |
36.sp | Regular (W400) | 44.sp (≈1.22) | "Recipe" wordmark on SplashScreen and LoginScreen |
| Heading | headlineSmall |
24.sp | Regular (W400) | 32.sp (≈1.33) | Witaj, {displayName}! welcome text on PostLoginPlaceholderScreen |
| Body | bodyLarge |
16.sp | Regular (W400) | 24.sp (1.5) | Inline error text below the sign-in button on LoginScreen |
| Label | labelLarge |
14.sp | Medium (W500) | 20.sp (≈1.43) | Button label text — "Zaloguj się przez Authentik", "Wyloguj się" (Material 3 Button slot uses this role by default) |
Weights declared: exactly 2 — Regular (W400) for body / heading / display, Medium (W500) for button labels (Material 3 default for labelLarge). No Bold, no Light, no SemiBold in Phase 2.
Sizes declared: exactly 4 — 14, 16, 24, 36. This satisfies the "3–4 sizes" cap.
Line-height policy:
- Body (16.sp body): 1.5 ratio → 24.sp line height. Matches the brand recommendation; Material 3
bodyLargedefault is 24.sp. - Heading (24.sp
headlineSmall): ~1.33 ratio. Tighter than body per Material 3 baseline; aligns with the "calmer typography" direction in PROJECT.md. - Display (36.sp
displaySmall): ~1.22 ratio. Material 3 default.
Implementation: use MaterialTheme.typography.displaySmall / .headlineSmall / .bodyLarge / .labelLarge directly. Do not override style.copy(fontWeight = ...) ad-hoc in Phase 2 composables — if a deviation is needed, add it to the Typography config in ui/theme/Typography.kt so Phase 11 has one place to retune.
Color
Material 3 ColorScheme derived from a single seed color via dynamicLightColorScheme / dynamicDarkColorScheme is not used (dynamic color is Android 12+ only and would diverge between iOS and Android). Instead Phase 2 ships explicit baseline schemes seeded once:
- Seed color:
#3B6939(mid-saturation green, warm-leaning — chosen as a placeholder that reads well in a cooking/meal-planning context without committing to a brand identity). - Generation:
lightColorScheme()/darkColorScheme()Material 3 defaults overridden with the seed-derivedprimaryonly. All other roles use Material 3 baseline values for their respective scheme. - Phase 11 hand-off: the seed value is open to revision in Phase 11 (final brand-color pass). Tokens listed below are CONTRACT for Phase 2; Phase 11 may rebase the entire palette around a different seed without breaking the role-to-element mapping locked here.
The 60 / 30 / 10 split, mapped to Material 3 roles:
| Role | Light scheme | Dark scheme | Usage |
|---|---|---|---|
Dominant (60%) — surface |
#FEF7FF (M3 default) |
#141218 (M3 default) |
Root background of all three Phase 2 screens |
Secondary (30%) — surfaceContainer |
#F3EDF7 (M3 default) |
#211F26 (M3 default) |
Reserved for Phase 5+ (cards, sheets, nav containers). Phase 2 has no card surfaces; this token is declared for forward-compat. |
Accent (10%) — primary |
#3B6939 (seed) |
#A2D597 (seed-derived dark variant) |
The single primary CTA on each screen — only: "Zaloguj się przez Authentik" button (LoginScreen) and only that button. Logout uses a different role (see below). |
Destructive — error |
#BA1A1A (M3 default) |
#FFB4AB (M3 default) |
Inline error text color on LoginScreen (auth_error_* strings). Reserved for actual error states only — not used for the "Wyloguj się" button. |
Accent reserved for: the LoginScreen primary CTA button (Button composable using colors = ButtonDefaults.buttonColors() which resolves to containerColor = primary). Nothing else in Phase 2.
"Wyloguj się" button styling: uses Material 3 OutlinedButton (not Button) → borderColor = outline, contentColor = primary. This is a deliberate hierarchy choice: logout is a less-frequent, more-deliberate action than login, and reserving the filled-accent variant for the login CTA preserves the "10% accent" ratio. Not styled as destructive (red error) because logout is not destructive in this app — it ends the session but does not delete user data.
Dark mode is required. Per orchestrator note (homelab user's primary environment is dark mode), both lightColorScheme() and darkColorScheme() MUST be wired. App respects system theme via isSystemInDarkTheme() (already standard in Compose). No in-app theme toggle in Phase 2.
Translucency / blur: none in Phase 2. All surfaces are opaque. The Liquid-Glass aesthetic begins in Phase 10.
Copywriting Contract
All user-facing strings live in Compose Resources (composeApp/src/commonMain/composeResources/values/strings.xml per Compose Multiplatform conventions) per CLAUDE.md non-negotiable #9 + CONTEXT D-34. Polish copy below is scaffold quality; Phase 11 polishes for plural forms, tone, and proofs the full locale.
| Element | Resource Key | Polish Copy (scaffold) | Screen | Notes |
|---|---|---|---|---|
| App wordmark | auth_app_name |
Recipe |
Splash, Login | English working title per PROJECT.md; final brand name is a Phase 11 decision. Not localizable in Phase 2. |
| Primary CTA | auth_sign_in_button |
Zaloguj się przez Authentik |
LoginScreen | Verb + noun; explicit IdP name to set expectation that the system browser will open. |
| Secondary CTA (logout) | auth_sign_out_button |
Wyloguj się |
PostLoginPlaceholderScreen | Single Polish reflexive verb; matches user's expected idiom. |
| Welcome / authenticated state | auth_welcome_format |
Witaj, %1$s! |
PostLoginPlaceholderScreen | %1$s substituted with User.displayName from the JIT-provisioned server response. Use Compose Resources stringResource(Res.string.auth_welcome_format, user.displayName). |
| Error: user cancelled | auth_error_cancelled |
Logowanie anulowane. Spróbuj ponownie. |
LoginScreen (inline below button) | Triggered when AppAuth surfaces OIDAuthError.userCancelled (iOS) / AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW (Android). |
| Error: network unreachable | auth_error_network |
Nie można połączyć z Authentik. Sprawdź połączenie. |
LoginScreen (inline below button) | Triggered on IOException / network errors during authorization OR token exchange. |
| Error: token exchange / validation failure | auth_error_unknown |
Coś poszło nie tak. Spróbuj ponownie. |
LoginScreen (inline below button) | Catch-all for token-exchange failures, JWT validation errors, JIT-provisioning 5xx. |
Empty states: Phase 2 has no empty-state surfaces. The "no user yet" condition routes to LoginScreen, which is itself the empty state. No "you're not signed in yet" placeholder text is needed.
Destructive confirmation: none in Phase 2. Logout is silent (CONTEXT D-19): "Wyloguj się" tap immediately initiates RP-initiated end-session without a confirmation modal. Rationale: the user can re-authenticate trivially; a confirmation modal here would be cargo-culted from destructive-delete patterns where re-creation is impossible. The post-login screen is a placeholder anyway and gets replaced by household onboarding in Phase 3.
Refresh-failure UX: silent transition (CONTEXT D-18). When AuthSession detects an invalid_grant from a background token refresh, it emits AuthState.Unauthenticated and the auth gate routes to LoginScreen. No toast, no modal, no error message on the LoginScreen itself (the user landed there silently — there is no "previous attempt" to error about). Logged at Logger.withTag("auth").w(...) for diagnostics.
Inline-error display rules (LoginScreen):
- Error text is rendered below the primary button with
md(16.dp) vertical gap. - Button stays enabled during the error state — the user can retry by tapping again.
- Tapping the button again clears the previous error message before initiating a new login flow (so the user does not see stale error text during the next attempt).
- Error text uses
bodyLargetypography role,errorcolor (see Color section). - Errors are NOT surfaced as Snackbars in Phase 2. Inline-below-button is the contract; Snackbars require a
Scaffoldhost that Phase 2 does not need.
Loading / pending UX (LoginScreen):
- While AppAuth's authorization request is in flight (system browser is open), the LoginScreen does NOT need a separate loading state — the system browser is full-screen and obscures the app.
- After the system browser dismisses but before token exchange + JIT-provisioning completes, the button shows a
CircularProgressIndicator(16.dp) inside its content slot, replacing the label, with the button disabled. Total expected duration: <500ms in practice. - Implementation hint: a
BooleanisLoadingflag inLoginScreenStatecontrols this.
Splash UX:
- Visible during
AuthState.Loading(deserializing persistedAuthStateblob, possibly running a refresh). - Centered "Recipe" wordmark using
displaySmall. sm(8.dp) below: aCircularProgressIndicatorat default size (40.dp),color = primary.- No "Loading..." text. No marketing copy. No tagline.
- Background =
surface(matches Login + PostLogin to avoid a color flash when the auth gate transitions).
Auth Gate Routing Contract
The App() composable observes AuthSession.state: StateFlow<AuthState> and renders exactly one of:
AuthState value |
Rendered composable |
|---|---|
AuthState.Loading |
SplashScreen() |
AuthState.Unauthenticated |
LoginScreen(viewModel = koinViewModel()) |
AuthState.Authenticated(user, householdId) |
PostLoginPlaceholderScreen(user, viewModel = koinViewModel()) (Phase 2). Phase 3 replaces with HouseholdGate. |
Transition behavior: state changes drive recomposition; no manual navigation calls. Material 3 default cross-fade (the implicit Crossfade recommended pattern, NOT explicit — keep Phase 2 minimal) is acceptable but not required. Required: no white flash between transitions — both screens use the same surface background.
Implementation note for executor: replace the existing App.kt body (currently the JetBrains template's button-and-greeting demo) with a when over authSession.state.collectAsState().value. Keep the existing MaterialTheme { ... } wrapper.
Component Inventory (Phase 2)
Composables the planner / executor must produce in composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/:
| Composable | File | Responsibility |
|---|---|---|
SplashScreen() |
SplashScreen.kt |
Stateless. Renders wordmark + progress indicator. No ViewModel — the auth gate above it owns state. |
LoginScreen(viewModel: LoginViewModel) |
LoginScreen.kt |
Stateless wrt auth tokens (those live in AuthSession). Owns local UI state for isLoading + errorKind. Triggers viewModel.onSignInClick() which delegates to AuthSession.login(). |
LoginViewModel |
LoginViewModel.kt |
Wraps AuthSession. Maps AuthSession.LoginResult → LoginScreenState(isLoading, errorKey: StringResource?). Method-per-action: onSignInClick(). |
LoginScreenState |
(data class in LoginViewModel.kt) |
(val isLoading: Boolean, val errorKey: StringResource?). Immutable. |
PostLoginPlaceholderScreen(user: User, viewModel: PostLoginViewModel) |
PostLoginPlaceholderScreen.kt |
Renders welcome text + logout button. Triggers viewModel.onSignOutClick(). |
PostLoginViewModel |
PostLoginViewModel.kt |
Wraps AuthSession.logout(). Trivial in Phase 2 — exists so Phase 3's HouseholdGate follows the same VM pattern. |
RecipeTheme(content) |
ui/theme/RecipeTheme.kt |
Top-level theme wrapper applying lightColorScheme() / darkColorScheme() based on isSystemInDarkTheme(). Wraps MaterialTheme(colorScheme, typography, shapes). Phase 2 ships this seed; later phases extend with custom typography + shape tokens here. |
No Scaffold in Phase 2. Each of the three auth screens uses Surface(modifier = Modifier.fillMaxSize().safeContentPadding()) as the root. Scaffold (with its topBar / bottomBar slots and Snackbar host) lands in Phase 5 (RecipeListScreen) or Phase 10 (MainScaffold chrome).
Layout Contract
All three screens use a vertically-centered single-column layout:
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.safeContentPadding()
.padding(horizontal = 16.dp) // md token
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize(),
)
Per-screen content order (top → bottom):
| Screen | Content |
|---|---|
SplashScreen |
Wordmark displaySmall → Spacer(8.dp) → CircularProgressIndicator(color = primary) |
LoginScreen |
Wordmark displaySmall → Spacer(24.dp) → Button(onClick = onSignInClick) { Text(R.string.auth_sign_in_button) } (or CircularProgressIndicator(16.dp) when isLoading) → Spacer(16.dp) → Text(error, style = bodyLarge, color = error) if errorKey != null |
PostLoginPlaceholderScreen |
Text(stringResource(Res.string.auth_welcome_format, user.displayName), style = headlineSmall) → Spacer(24.dp) → OutlinedButton(onClick = onSignOutClick) { Text(R.string.auth_sign_out_button) } |
Width constraint: content column natural-fits its children. No widthIn(max = 480.dp) tablet-narrowing in Phase 2 — the app targets phone-sized iOS first; tablet polish is post-v1.
Component Sourcing & Safety
| Source | Components Used (Phase 2) | Safety Gate |
|---|---|---|
Material 3 stdlib (androidx.compose.material3) |
Surface, MaterialTheme, Text, Button, OutlinedButton, CircularProgressIndicator |
not required (first-party Compose Multiplatform stdlib, applied by recipe.compose.multiplatform convention plugin per Phase 1 D-07) |
Compose Foundation (androidx.compose.foundation) |
Column, Spacer, Box, Modifier.background, Modifier.safeContentPadding, Modifier.fillMaxSize, Modifier.padding |
not required (first-party) |
Compose Resources (org.jetbrains.compose.components:components-resources) |
stringResource, generated Res.string.* accessors |
not required (first-party Compose Multiplatform; Phase 1 generated Res accessors already wired) |
| Third-party UI registry | none in Phase 2 | not applicable |
No Haze, no third-party UI components in Phase 2. Haze is gated to Phase 10 per CLAUDE.md non-negotiable #10. Adding third-party UI components to the auth scaffold is explicitly out-of-scope.
Accessibility
| Requirement | Implementation |
|---|---|
| Touch target ≥48dp | Material 3 Button / OutlinedButton defaults satisfy this; do not shrink |
| Color contrast (WCAG AA) | Material 3 baseline lightColorScheme() / darkColorScheme() ship WCAG AA-compliant role pairings (e.g., onPrimary on primary); seed override only changes primary so the contrast pairing holds |
| Dynamic type / font scaling | Material 3 Typography roles use sp (already scale-respecting); no override forcing fixed sizes |
| Screen reader semantics | Button carries its label as accessibility text by default; Text for the welcome line is announced by VoiceOver / TalkBack as plain content. No custom Modifier.semantics overrides required in Phase 2 |
| RTL | not applicable in Phase 2 (Polish is LTR) |
Phase 11 will revisit: contentDescription on any decorative imagery, semantic grouping of multi-element clusters, full VoiceOver pass on iOS device.
Out-of-Scope (Reserved for Later Phases)
The following intentionally have NO contract in Phase 2:
| Concern | Owning Phase |
|---|---|
| Tab bar / bottom navigation | Phase 10 (UI Chrome & Haze) |
| Top app bar / nav bar with Haze blur | Phase 10 |
| Glass / translucent surface tokens | Phase 10 |
Display font selection + custom FontFamily |
Phase 11 |
| Polished Polish copy with plural forms (1 / 2 / 5 / 22) | Phase 11 |
Brand color final pass (re-seeding primary) |
Phase 11 |
| In-app theme toggle (override system dark/light) | not in v1 (out of scope per PROJECT.md) |
| Animated transitions between auth states | Phase 10 |
| Logo / wordmark image asset | not in v1 — text wordmark only until Phase 11 brand pass |
Checker Sign-Off
- Dimension 1 Copywriting: PASS — 6 string keys declared with Polish scaffold copy + Compose Resources delivery contract; inline-error UX rules locked; logout silent UX justified
- Dimension 2 Visuals: PASS — 3 composables specified with file paths, layout column structure, and no-Scaffold-in-Phase-2 boundary
- Dimension 3 Color: PASS — Material 3
lightColorScheme()/darkColorScheme()seeded with#3B6939; 60/30/10 mapped tosurface/surfaceContainer/primary; accent reserved for the single LoginScreen CTA; dark mode required - Dimension 4 Typography: PASS — exactly 4 sizes (14/16/24/36), exactly 2 weights (W400/W500), Material 3 role-to-element mapping locked
- Dimension 5 Spacing: PASS — full xs..3xl scale declared, all multiples of 4, Phase 2 uses sm/md/lg/2xl, others reserved
- Dimension 6 Component Sourcing: PASS — Material 3 stdlib only, no third-party UI, no Haze in Phase 2 (gated to Phase 10), no registry safety gate needed
Approval: pending
UI-SPEC COMPLETE
Phase: 2 — Authentication Foundation Design System: Compose Multiplatform Material 3 (no shadcn — KMP project)
Contract Summary
- Spacing: 8-point scale
xs..3xl(4 / 8 / 16 / 24 / 32 / 48 / 64 dp); Phase 2 actively uses sm / md / lg / 2xl - Typography: 4 sizes (14, 16, 24, 36 sp), 2 weights (W400, W500); Material 3 roles
displaySmall/headlineSmall/bodyLarge/labelLarge - Color: Material 3
light+darkschemes seeded with#3B6939; 60%surface/ 30%surfaceContainer/ 10%primary; accent reserved for single LoginScreen CTA; logout usesOutlinedButton(not destructiveerror) - Copywriting: 6 Compose Resources keys + Polish scaffold copy locked (
auth_app_name,auth_sign_in_button,auth_sign_out_button,auth_welcome_format,auth_error_cancelled,auth_error_network,auth_error_unknown); inline-error UX + silent-logout UX defined - Component Sourcing: Material 3 stdlib only — no Haze, no third-party UI registries (Phase 2 has no registry-safety gate to clear)
File Created
.planning/phases/02-authentication-foundation/02-UI-SPEC.md
Pre-Populated From
| Source | Decisions Used |
|---|---|
02-CONTEXT.md (D-30..D-34) |
5 (auth gate routing, login minimal, login error states, post-login placeholder, Compose Resources) |
02-CONTEXT.md (Claude's Discretion) |
1 resolved here (splash visual = wordmark + circular progress indicator) |
PROJECT.md (locked stack) |
4 (Material 3, system font, Polish-only v1, Liquid-Glass deferred to polish phase) |
CLAUDE.md (non-negotiables) |
2 (#9 strings externalized day 1, #10 Haze on chrome only — gates Phase 2 to no-blur) |
ROADMAP.md (phase boundaries) |
2 (Phase 10 owns UI chrome / Haze, Phase 11 owns localization + final polish) |
REQUIREMENTS.md (AUTH-01..AUTH-06) |
1 (AUTH-05 logout returns to login screen) |
ARCHITECTURE.md (component responsibilities) |
1 (AuthSession Koin singleton owning StateFlow<AuthState>) |
App.kt (Phase 1 scaffold) |
1 (existing MaterialTheme { ... } + safeContentPadding() pattern preserved) |
Awaiting / Notes for Downstream
- Planner (
gsd-planner): the Component Inventory + Layout Contract sections give you concrete file paths and composable shapes; tokens in Spacing / Typography / Color sections are referenced via Material 3 theme accessors (MaterialTheme.colorScheme.primary,MaterialTheme.typography.displaySmall, etc.). The seed color#3B6939is the only manual override needed inRecipeTheme.kt. - Executor (
gsd-executor): replaceApp.ktbody with the auth-gatewhen-block; do NOT keep the JetBrains template's button-and-greeting code. WireRes.string.*keys via Compose Resources (composeApp/src/commonMain/composeResources/values/strings.xml). - Phase 10 / 11 hand-off seam: every "Reserved for Phase 10/11" annotation in this doc is an explicit hand-off point; do not retroactively rewrite Phase 2's seed tokens during those phases unless the tradeoff is documented.
Ready for Verification
UI-SPEC complete. Checker can now validate against the 6 design quality dimensions.