Files
recipe/.planning/phases/02-authentication-foundation/02-UI-SPEC.md
2026-04-29 20:54:13 +02:00

24 KiB
Raw Blame History

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 3 Type / ColorScheme roles.

Phase boundary reminder: Phase 2 ships SCAFFOLD UI quality — three composables (SplashScreen, LoginScreen, PostLoginPlaceholderScreen) plus the auth gate in App(). 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 "34 sizes" cap.

Line-height policy:

  • Body (16.sp body): 1.5 ratio → 24.sp line height. Matches the brand recommendation; Material 3 bodyLarge default 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-derived primary only. 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 bodyLarge typography role, error color (see Color section).
  • Errors are NOT surfaced as Snackbars in Phase 2. Inline-below-button is the contract; Snackbars require a Scaffold host 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 Boolean isLoading flag in LoginScreenState controls this.

Splash UX:

  • Visible during AuthState.Loading (deserializing persisted AuthState blob, possibly running a refresh).
  • Centered "Recipe" wordmark using displaySmall.
  • sm (8.dp) below: a CircularProgressIndicator at 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.LoginResultLoginScreenState(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 displaySmallSpacer(8.dp)CircularProgressIndicator(color = primary)
LoginScreen Wordmark displaySmallSpacer(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 to surface / 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 + dark schemes seeded with #3B6939; 60% surface / 30% surfaceContainer / 10% primary; accent reserved for single LoginScreen CTA; logout uses OutlinedButton (not destructive error)
  • 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 #3B6939 is the only manual override needed in RecipeTheme.kt.
  • Executor (gsd-executor): replace App.kt body with the auth-gate when-block; do NOT keep the JetBrains template's button-and-greeting code. Wire Res.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.