feat(kotlin-compose): design system + 33 components + gallery_kt + e2e android emulator + scaffolder fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 16:28:50 +02:00
parent 0bdb8454e1
commit cb6d9e61d1
152 changed files with 148262 additions and 25 deletions
+348
View File
@@ -0,0 +1,348 @@
# Kotlin Compose patterns — @fn_compose
Pautas autoritativas para apps Android Kotlin Compose del registry. Mirror de `cpp/PATTERNS.md` (C++ ImGui) y `frontend/functions/ui/` (web Mantine).
## 1. Dark-first
`FnTheme` aplica dark por defecto. Mirror del web stack donde `FnMantineProvider` hardcodea `defaultColorScheme="dark"`.
```kotlin
setContent {
FnTheme { // darkMode = true por default
Surface(Modifier.fillMaxSize()) { ... }
}
}
// Override explicito:
FnTheme(darkMode = false) { ... } // forzar light
FnTheme(darkMode = isSystemInDarkTheme()) { } // seguir sistema
```
NUNCA `MaterialTheme {}` directo. SIEMPRE `FnTheme {}`.
## 2. Tokens — fuente unica
Todo valor visual via `fn.compose.theme.FnTokens` o sus alias directos:
```
FnColors paleta Mantine 50→950 (gray, brand) + status (red, green, yellow, blue) + pure
FnSpacing xs=8 sm=12 md=16 lg=24 xl=32 xxl=48 (Dp)
FnRadius none=0 xs=2 sm=4 md=8 lg=12 xl=16 full=9999 (Dp)
FnTypography sizes xs..xl + h1..h6 + weights normal/medium/semibold/bold + ready TextStyles
FnShadows none/xs/sm/md/lg/xl elevation (Dp)
```
**Regla:** si haces `Color(0xFF...)` o `8.dp` literal en codigo app, ESTA MAL. Usar tokens.
Excepcion permitida: literales en helpers privados que SOLO retornan tokens (ej. `categoryColor(c) -> FnBadgeColor`).
## 3. Componentes propios, no Material3 directo
```
Material3 raw ← solo dentro de @fn_compose
@fn_compose ← apps consumen estos
App-specific UI ← logica del dominio
```
Apps importan `fn.compose.ui.FnButton`, NUNCA `androidx.compose.material3.Button`.
Primitivas Compose foundation OK en apps: `Box`, `Column`, `Row`, `Spacer`, `Modifier`, `LazyColumn`. Preferir `FnStack`/`FnGroup` para layouts agregados.
## 4. Catalogo de componentes (28)
### Theming (8)
- `FnTheme` — provider
- `FnTokens` — agregador
- `FnColors` / `FnSpacing` / `FnRadius` / `FnTypography` / `FnShadows`
### Layout (4)
- `FnStack` — Column con gap
- `FnGroup` — Row con gap
- `FnPaper` — Surface con tokens
- `FnAppShell` — Scaffold
### Display (5)
- `FnText` — body xs..xl
- `FnTitle` — h1..h6
- `FnCard` — variants Default/Borderless/Ghost
- `FnBadge` — pill 6 colores semanticos
- `FnAvatar` — circle con initials, Sm/Md/Lg
### Input (5)
- `FnButton` — variants Filled/Outlined/Secondary/Ghost/Destructive/Link
- `FnTextInput` — OutlinedTextField + label/error
- `FnSelect` — ExposedDropdownMenu
- `FnSwitch` / `FnCheckbox`
### Navigation (1)
- `FnTabs` — TabRow + ScrollableTabRow
### Feedback (4)
- `FnAlert` — Info/Success/Warning/Error theme-aware
- `FnLoader` — CircularProgress Sm/Md/Lg
- `FnSkeleton` — animated shimmer
- `FnDialog` — AlertDialog wrapper + destructive variant
### Data (3)
- `FnDataTable` — Column-based table generico
- `FnKpiCard` — label + value + delta + sparkline
- `FnPageHeader` — title + subtitle + actions slot
- `FnEmptyState` — icon + title + description + action
### Charts (3)
- `FnLineChart` — Canvas-based, con area fill opcional
- `FnBarChart` — vertical bars + labels
- `FnSparkline` — mini inline chart (KPIs, tablas)
## 5. Variants estandar (mirror Mantine)
| Componente | Variants |
|---|---|
| `FnButton` | Filled, Outlined, Secondary, Ghost, Destructive, Link |
| `FnCard` | Default (border+shadow), Borderless (bg only), Ghost (transparent) |
| `FnBadge` | Brand, Gray, Green, Red, Yellow, Blue |
| `FnAlert` | Info, Success, Warning, Error (theme-aware en dark+light) |
| `FnAvatar` | Sm (28dp), Md (40dp), Lg (56dp) |
| `FnLoader` | Sm (16dp), Md (32dp), Lg (56dp) |
| `FnText` | size Xs/Sm/Md/Lg/Xl |
| `FnTitle` | order 1..6 |
## 6. Layout patterns
### App shell estandar
```kotlin
FnAppShell(title = "Mi App") { padding ->
FnStack(
modifier = Modifier.padding(padding).fillMaxSize(),
gap = FnSpacing.md,
) {
FnPageHeader("Seccion", subtitle = "Descripcion")
// content...
}
}
```
Body slot recibe `PaddingValues` → aplicar SIEMPRE para no solapar topbar.
### Lista de items
```kotlin
LazyColumn(verticalArrangement = Arrangement.spacedBy(FnSpacing.sm)) {
items(data) { item -> ItemRow(item) }
}
```
### Tabs navegacion entre categorias
```kotlin
var tab by remember { mutableStateOf(0) }
FnTabs(tabs = categorias, selectedIndex = tab, onTabSelected = { tab = it }, scrollable = true)
when (tab) { 0 -> ScreenA(); 1 -> ScreenB() ... }
```
### Dashboard grid KPIs
```kotlin
FnGroup(gap = FnSpacing.sm) {
FnKpiCard(label = "Revenue", value = "€42k", sparklineData = ..., modifier = Modifier.weight(1f))
FnKpiCard(label = "Churn", value = "3.1%", sparklineData = ..., modifier = Modifier.weight(1f))
}
```
### Tabla
```kotlin
FnDataTable(
rows = items,
modifier = Modifier.fillMaxWidth(),
columns = listOf(
FnTableColumn(header = "Nombre", weight = 2f, cell = { FnText(it.name) }),
FnTableColumn(header = "Estado", weight = 1f, cell = { FnBadge(it.status, color = ...) }),
),
)
```
NO anidar `FnDataTable` dentro de `Modifier.verticalScroll()` con muchas rows — usa `LazyColumn` arriba o limita altura. FnDataTable internamente usa Column estatica.
## 7. Programacion funcional
- **Datos inmutables** — `data class` con `val`
- **Pure helpers** — Composables stateless. Transformaciones `state → state` puras fuera de Composables
- **State via mutableStateOf** — Compose lo requiere para reactividad
- **Transformaciones** — `state = puraTransform(state, evento)`. NUNCA `state.add(...)`
- **Side effects bordes** — solo en callbacks (`onClick`, `onValueChange`)
- **Tests JVM puros** — `PureLogicTest.kt` valida helpers sin Compose
Ejemplo canonico:
```kotlin
internal fun appendMyMessage(messages: List<Message>, text: String): List<Message> =
if (text.isBlank()) messages
else messages + Message("Yo", text, "ahora", true)
@Composable
fun ChatScreen() {
var msgs by remember { mutableStateOf(MOCK_MESSAGES) }
var draft by remember { mutableStateOf("") }
ChatContent(
messages = msgs,
draft = draft,
onDraftChange = { draft = it },
onSend = {
msgs = appendMyMessage(msgs, draft) // pure
draft = ""
},
)
}
```
## 8. Testing — 3 niveles
| Nivel | Donde | Que valida | Cuando |
|---|---|---|---|
| `PureLogicTest` | `src/test/.../` | Funciones puras JVM | Cada PR |
| `ExampleScreenshotTest` (Roborazzi) | `src/test/.../` | Snapshots Composables light+dark+edge | Cada PR |
| `MainActivityTest` (instrumented) | `src/androidTest/.../` | Real device/emulator clicks | E2E gate |
Pattern Roborazzi:
```kotlin
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h800dp-xhdpi")
class ScreenshotTest {
@get:Rule val composeTestRule = createComposeRule()
@Test fun snapshotLight() {
composeTestRule.setContent { FnTheme(darkMode = false) { Surface { ScreenContent(...) } } }
composeTestRule.onRoot().captureRoboImage("src/test/snapshots/images/screen_light.png")
}
}
```
Comando record goldens: `./gradlew :app:recordRoborazziDebug`. Verify: `:app:verifyRoborazziDebug`. Goldens (.png) se commitean.
## 9. testTag para tests instrumented
Componentes interactivos llevan `Modifier.testTag("nombre_corto")`:
```kotlin
Button(modifier = Modifier.testTag("btn_submit"), ...)
```
Test:
```kotlin
composeTestRule.onNodeWithTag("btn_submit").performClick()
```
NUNCA `onNodeWithText("Submit")` — rompe al traducir strings o si texto duplicado.
Texto duplicado en tree → `onAllNodesWithText("X")[0]` en lugar de `onNodeWithText`. Detectado real: tests fallan con `Expected at most 1 node but found 2` si dos componentes muestran mismo string.
## 10. Visibilidad vs existencia
- `assertIsDisplayed()` — visible en viewport. Falla si below-the-fold.
- `assertExists()` — solo presente en tree. Para items en scroll containers.
Regla: items below viewport en scroll → `assertExists()`.
## 11. e2e_checks declarado en app.md
Toda app nueva (via scaffolder) genera bloque:
```yaml
e2e_checks:
- id: unit # gradle :app:test
- id: screenshot # gradle :app:verifyRoborazziDebug
- id: build # gradle :app:assembleDebug
- id: emu_start # arrancar AVD
- id: instrumented # gradle :app:connectedAndroidTest
- id: emu_stop # cleanup
```
`fn-analizador` consume esto en fase 4 del bucle reactivo.
## 12. Composite build local
Apps importan `@fn_compose` via `includeBuild("../../kotlin/functions/ui")` en `settings.gradle.kts`. NO maven artifact. Cambios en library → apps recompilan auto.
## 13. Scaffolder canonico (OBLIGATORIO)
Apps Kotlin nuevas SIEMPRE via:
```bash
./fn run init_kotlin_app <name> [--project <p>] [--package com.x.y]
```
NUNCA crear MainActivity + build.gradle.kts + app.md a mano. Si scaffolder no cubre un caso, modifica el scaffolder. Plantilla = codigo, no decoracion.
## 14. Equivalencias cross-stack
```
Concepto Web Mantine C++ ImGui Android Compose
───────────── ─────────────── ────────────── ───────────────
Provider <MantineProvider> fn::run_app cfg FnTheme {}
Default theme dark ThemeMode::FnDark darkMode=true
Tokens theme.colors fn_tokens::colors FnColors / FnTokens
Container <Paper> fn_ui::Card FnPaper / FnCard
Button variants default/outline/... n/a Filled/Outlined/...
Card variants default/borderless/... n/a Default/Borderless/Ghost
Layout col <Stack> ImGui::BeginGroup FnStack
Layout row <Group> SameLine FnGroup
Heading <Title order={N}> PushFont(big) FnTitle order=N
Body text <Text size="md"> Text(...) FnText size=Md
Badge <Badge> colored span FnBadge
Alert <Alert> message box FnAlert
Loader <Loader> spinner widget FnLoader
Skeleton <Skeleton> n/a FnSkeleton
App scaffold <AppShell> dockspace + menu FnAppShell
Tabs <Tabs> ImGui::BeginTabBar FnTabs
Modal <Dialog> modal_dialog FnDialog
Avatar <Avatar> n/a FnAvatar
Select <Select> ImGui::Combo FnSelect
Toggle <Switch> ImGui::Checkbox FnSwitch
Table <DataTable> fn_ui::table FnDataTable
Line chart <LineChart> fn_viz::line_plot FnLineChart
Bar chart <BarChart> fn_viz::bar_chart FnBarChart
Sparkline <Sparkline> n/a FnSparkline
KPI card <KPICard> n/a FnKpiCard
Empty state <EmptyState> n/a FnEmptyState
Page header <PageHeader> n/a FnPageHeader
```
Cambios paleta brand en un sitio → propagan cross-stack manualmente. Tracker drift en backlog.
## 15. Gallery showcase
App `apps/gallery_kt/` muestra los 28 componentes organizados por categoria con FnTabs. Sirve como:
- Regression visual gate via Roborazzi snapshots
- Onboarding rapido para devs nuevos
- Documentacion ejecutable
Rebuilds y screenshots: `./fn run run_kotlin_app_tests apps/gallery_kt`.
## 16. Anti-patterns
| Anti-patron | Por que mal | Sustituir por |
|---|---|---|
| `Color(0xFFAABBCC)` en app code | Bypass tokens | `FnColors.gray700` |
| `8.dp` literal en app code | Bypass spacing | `FnSpacing.xs` |
| `MaterialTheme {}` en app | No usa tokens design system | `FnTheme {}` |
| `androidx.compose.material3.Button` directo | Sin variants ni shape consistente | `FnButton` |
| `var msgs = mutableListOf<X>()` | Mutable state mutable | `var msgs by remember { mutableStateOf(emptyList<X>()) }` |
| `state.add(x)` | Mutacion in-place | `state = state + x` |
| Helpers @Composable solo para colores | Side-effect implicito | Funcion pura que retorna `FnBadgeColor` enum |
| `LazyColumn` dentro de `verticalScroll` | Infinity constraint crash | `Column` o limitar altura |
| `onNodeWithText` para tests | Romper en traducciones / duplicados | `testTag` + `onNodeWithTag` |
| Logica de negocio inline en MainActivity | No testeable | Extraer helpers puros + `internal` para tests |
| Scaffolder a mano | Drift entre apps | `fn run init_kotlin_app` |
## 17. Reglas de incorporacion de componentes nuevos
1. Buscar primero en registry — `mcp__registry__fn_search` por nombre/desc/code
2. Mirar el equivalente Mantine (`frontend/functions/ui/`) para inspirar API
3. Crear via `fn-constructor` con prompt tight (no inline en apps)
4. `lang: kt`, `domain: ui`, `kind: component`, `framework: compose`, `error_type: error_go_core`
5. Theme-aware: usar `MaterialTheme.colorScheme.*` para colores semanticos
6. Tokens primero: `FnSpacing.*`, `FnRadius.*`, `FnTypography.*` antes que valores raw
7. Stateless preferido — caller mantiene state via `remember`
8. Variants via `enum class` — no booleanos sueltos
9. `Modifier.testTag` en cualquier elemento interactivo
10. Update gallery_kt con seccion nueva tras crearlo