mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory. Co-authored-by: Junie <junie@jetbrains.com>
171 lines
5.0 KiB
Markdown
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)))
|
|
}
|
|
}
|
|
```
|