Files
Hammed Abass fd4fff2c86 docs: add compose development guidelines and skills
Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory.

Co-authored-by: Junie <junie@jetbrains.com>
2026-07-07 16:55:43 +02:00

5.0 KiB

UI/UX Patterns for Utility Apps

Core Principles

Utility apps are trust products. The UI must feel: stable, immediate, precise, reversible, non-destructive.

Loading States

Decision Rule

Situation Best default
First load, known result card layout skeleton
Small inline refresh of one section keep content + small inline indicator
Whole-screen blocking startup with no known structure spinner, but rare
Recalculating quote while old result exists keep old result + "updating" affordance
Empty but idle state empty-state hint, not spinner

Default Recommendation

  • Skeleton: default for known layout with missing data
  • Subtle shimmer over skeleton: optional polish, not the primary strategy
  • Spinner: only for small unknown-layout operations or blocking tasks with no stable placeholder shape

Stable Layout During Loading

Never wipe content during refresh. Never cause height jumps, flicker, or lost context.

Inline Validation

Default behavior:

  • Validate format/range as user edits for fields where feedback is obvious
  • Avoid screaming errors on untouched fields
  • Show errors inline, next to the field they belong to
  • Do not collapse layout when error appears/disappears
  • Disable submit when impossible, but also explain why

Good inline validation behavior

  • Field keeps its value during error
  • Error appears under field
  • Submit remains disabled only when necessary
  • No modal dialog for every invalid keystroke
  • No full-form red error wall

Disabled States

Disabled is fine only when:

  • The reason is obvious from nearby context
  • The screen is still readable
  • User input is preserved

Bad disabled state: button disabled with no visible reason, form cleared during loading, entire screen grayed out for a small refresh.

Preserving User Input

Non-negotiable rules:

  • Never clear edited fields on refresh
  • Never clear last good result while fetching a new one
  • Never wipe the screen because one request failed

Progressive Disclosure

For dense forms:

  • Hide advanced options by default
  • Keep main path obvious
  • Reveal secondary controls progressively
  • Do not split trivial forms into too many steps

Partial Results

Good pattern:

  • Compute instant local estimate from current draft
  • Show local estimate immediately
  • Fetch remote refinement in background
  • Keep old refined quote until new one arrives
  • Label refreshed state clearly

Perceived Performance

For form-heavy screens:

  • Apply local field state changes instantly
  • Recalculate cheap deterministic outputs immediately
  • Debounce only expensive async work
  • Keep layout stable
  • Animate only meaningful content changes

Accessibility

  • Error messages must be text, not color only
  • Loading indicators should not hide context unnecessarily
  • Support logical keyboard/focus order
  • Avoid rapid flashing/sweeping shimmer
  • Keep controls large enough for data-entry reliability

Code Examples

BAD: disappearing content and layout jumps

@Composable
fun QuoteSection(quote: QuoteUi?, isLoading: Boolean) {
    if (isLoading) {
        CircularProgressIndicator()
    } else if (quote != null) {
        QuoteContent(quote = quote, refreshing = false)
    }
}

GOOD: stable layout with old content preserved

@Composable
fun QuoteSection(quote: QuoteUi?, isLoading: Boolean) {
    ResultCardSlot {
        when {
            quote != null -> QuoteContent(quote = quote, refreshing = isLoading)
            isLoading -> QuoteCardSkeleton()
            else -> QuoteEmptyState()
        }
    }
}

GOOD: stable placeholder slot

@Composable
fun ResultCardSlot(content: @Composable BoxScope.() -> Unit) {
    Box(modifier = Modifier.fillMaxWidth().heightIn(min = 180.dp)) { content() }
}

GOOD: skeleton with shimmer

@Composable
fun QuoteCardSkeleton(modifier: Modifier = Modifier) {
    val alpha by rememberInfiniteTransition(label = "skeleton").animateFloat(
        initialValue = 0.35f,
        targetValue = 0.60f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 800),
            repeatMode = RepeatMode.Reverse,
        ),
        label = "alpha",
    )

    Column(
        modifier = modifier
            .fillMaxWidth()
            .heightIn(min = 180.dp)
            .clip(RoundedCornerShape(16.dp))
            .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha))
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Box(Modifier.fillMaxWidth(0.4f).height(20.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
        Box(Modifier.fillMaxWidth().height(36.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
        Box(Modifier.fillMaxWidth(0.7f).height(20.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
    }
}