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

171 lines
5.0 KiB
Markdown

# 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
```kotlin
@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
```kotlin
@Composable
fun QuoteSection(quote: QuoteUi?, isLoading: Boolean) {
ResultCardSlot {
when {
quote != null -> QuoteContent(quote = quote, refreshing = isLoading)
isLoading -> QuoteCardSkeleton()
else -> QuoteEmptyState()
}
}
}
```
### GOOD: stable placeholder slot
```kotlin
@Composable
fun ResultCardSlot(content: @Composable BoxScope.() -> Unit) {
Box(modifier = Modifier.fillMaxWidth().heightIn(min = 180.dp)) { content() }
}
```
### GOOD: skeleton with shimmer
```kotlin
@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)))
}
}
```