tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Definitions

Nimbo: nuevo módulo :nimbo — clima como pintura ambiente (Open-Meteo + cielo vivo)

App de clima donde el cielo vivo ES la interfaz: reutiliza el motor de fondo de
Órbita (manchas Lissajous con mezcla aditiva cuya paleta deriva de hora local +
clima) a pantalla completa, y encima pinta un pronóstico real.

- Datos Open-Meteo sin clave ni deps (HttpURLConnection): actual + 24h + 7 días,
  con timezone=auto. Ubicación aproximada del sistema (last-known) y geocoding
  inverso (Android Geocoder) para el nombre del lugar.
- Hora local del lugar calculada al vuelo desde utc_offset → el cielo es correcto
  aunque el dato venga de caché.
- Caché del último pronóstico en DataStore (JSON crudo): abre al instante/offline
  y refresca en segundo plano.
- Glifos de clima monoline dibujados a mano en Canvas (sol/luna, nube, niebla,
  lluvia, nieve, tormenta), sin emojis.
- UI: temperatura grande, métricas (sensación/viento/humedad), franja por horas
  con prob. de precipitación, lista de 7 días con barras frío→cálido.
- Permiso de ubicación pedido en runtime; estados de carga/permiso/error.
- Estética del rizoma: neón sobre vacío, español minúscula. minSdk 26, sin clave.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

cb2d1062c914d9fc0ffb940433339f584167f45e · by sergio · (4 months ago) · parent 12ead2dfd · browse the tree at this commit

Semantic delta

No definition changed: this change is text, documentation or data.

19 files · +1171 −0

diff --git a/nimbo/build.gradle.kts b/nimbo/build.gradle.kts
new file mode 100644
index 0000000..2477edc
--- /dev/null
+++ b/nimbo/build.gradle.kts
@@ -0,0 +1,50 @@
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.kotlin.android)
+    alias(libs.plugins.kotlin.compose)
+}
+
+android {
+    namespace = "com.rizoma.nimbo"
+    compileSdk = 36
+
+    defaultConfig {
+        applicationId = "com.rizoma.nimbo"
+        minSdk = 26
+        targetSdk = 36
+        versionCode = 1
+        versionName = "0.1.0"
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+        }
+    }
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+    buildFeatures {
+        compose = true
+    }
+}
+
+dependencies {
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.androidx.lifecycle.runtime.ktx)
+    implementation(libs.androidx.lifecycle.viewmodel.compose)
+    implementation(libs.androidx.activity.compose)
+    implementation(platform(libs.androidx.compose.bom))
+    implementation(libs.androidx.compose.ui)
+    implementation(libs.androidx.compose.ui.graphics)
+    implementation(libs.androidx.compose.ui.util)
+    implementation(libs.androidx.compose.foundation)
+    implementation(libs.androidx.compose.animation)
+    implementation(libs.androidx.compose.material3)
+    implementation(libs.androidx.datastore.preferences)
+    implementation(libs.kotlinx.coroutines.android)
+}
diff --git a/nimbo/src/main/AndroidManifest.xml b/nimbo/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c5c2508
--- /dev/null
+++ b/nimbo/src/main/AndroidManifest.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <!-- Clima local: ubicación aproximada + red (Open-Meteo, sin clave). -->
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
+
+    <application
+        android:label="@string/app_name"
+        android:icon="@mipmap/ic_launcher"
+        android:theme="@style/Theme.Nimbo"
+        android:supportsRtl="true">
+
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:launchMode="singleTask"
+            android:configChanges="orientation|screenSize|keyboard|keyboardHidden|uiMode">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/MainActivity.kt b/nimbo/src/main/java/com/rizoma/nimbo/MainActivity.kt
new file mode 100644
index 0000000..f91f293
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/MainActivity.kt
@@ -0,0 +1,20 @@
+package com.rizoma.nimbo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.view.WindowCompat
+import com.rizoma.nimbo.ui.NimboApp
+import com.rizoma.nimbo.ui.theme.NimboTheme
+
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+        setContent {
+            NimboTheme {
+                NimboApp()
+            }
+        }
+    }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/NimboViewModel.kt b/nimbo/src/main/java/com/rizoma/nimbo/NimboViewModel.kt
new file mode 100644
index 0000000..9e81b70
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/NimboViewModel.kt
@@ -0,0 +1,54 @@
+package com.rizoma.nimbo
+
+import android.app.Application
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.rizoma.nimbo.data.Forecast
+import com.rizoma.nimbo.data.SkyRepository
+import kotlinx.coroutines.launch
+
+enum class Status { Loading, NeedsPermission, Ready, Error }
+
+class NimboViewModel(app: Application) : AndroidViewModel(app) {
+    private val repo = SkyRepository(app)
+
+    var forecast by mutableStateOf<Forecast?>(null); private set
+    var status by mutableStateOf(Status.Loading); private set
+    var refreshing by mutableStateOf(false); private set
+
+    init {
+        viewModelScope.launch {
+            repo.cached()?.let { forecast = it; status = Status.Ready }
+            when {
+                repo.hasLocation() -> refresh()
+                forecast == null -> status = Status.NeedsPermission
+            }
+        }
+    }
+
+    /** ¿Hay que lanzar la petición de permiso al arrancar? */
+    fun shouldAskPermission(): Boolean = !repo.hasLocation()
+
+    fun onPermission(granted: Boolean) {
+        if (granted) refresh()
+        else if (forecast == null) status = Status.NeedsPermission
+    }
+
+    fun refresh() {
+        if (refreshing) return
+        refreshing = true
+        viewModelScope.launch {
+            val fc = repo.fetch()
+            if (fc != null) {
+                forecast = fc
+                status = Status.Ready
+            } else if (forecast == null) {
+                status = if (repo.hasLocation()) Status.Error else Status.NeedsPermission
+            }
+            refreshing = false
+        }
+    }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/data/Cache.kt b/nimbo/src/main/java/com/rizoma/nimbo/data/Cache.kt
new file mode 100644
index 0000000..5cff37d
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/data/Cache.kt
@@ -0,0 +1,40 @@
+package com.rizoma.nimbo.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+
+private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "nimbo")
+
+/** Snapshot crudo del último pronóstico, para abrir al instante (y offline). */
+data class Snapshot(val json: String, val place: String, val updatedMs: Long)
+
+/** Caché del último pronóstico en DataStore (JSON crudo + lugar + marca de tiempo). */
+class Cache(context: Context) {
+    private val store = context.applicationContext.dataStore
+
+    suspend fun read(): Snapshot? {
+        val p = store.data.first()
+        val json = p[KEY_JSON] ?: return null
+        return Snapshot(json, p[KEY_PLACE].orEmpty(), p[KEY_TIME] ?: 0L)
+    }
+
+    suspend fun write(json: String, place: String, updatedMs: Long) {
+        store.edit { p ->
+            p[KEY_JSON] = json
+            p[KEY_PLACE] = place
+            p[KEY_TIME] = updatedMs
+        }
+    }
+
+    private companion object {
+        val KEY_JSON = stringPreferencesKey("forecast_json")
+        val KEY_PLACE = stringPreferencesKey("place")
+        val KEY_TIME = longPreferencesKey("updated_ms")
+    }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/data/Forecast.kt b/nimbo/src/main/java/com/rizoma/nimbo/data/Forecast.kt
new file mode 100644
index 0000000..08d80ff
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/data/Forecast.kt
@@ -0,0 +1,45 @@
+package com.rizoma.nimbo.data
+
+/** Lo que pinta la app: lugar, estado actual, próximas horas y próximos días. */
+data class Forecast(
+    val place: String,
+    val utcOffsetSeconds: Int,
+    val current: Current,
+    val hourly: List<HourSlot>,
+    val daily: List<DaySlot>,
+    val updatedEpochMs: Long,
+)
+
+data class Current(
+    val code: Int,
+    val temperatureC: Float,
+    val apparentC: Float,
+    val isDay: Boolean,
+    val humidity: Int,
+    val windKmh: Float,
+    val precipMm: Float,
+)
+
+data class HourSlot(
+    val label: String,      // "ahora", "18", "19"…
+    val temperatureC: Float,
+    val code: Int,
+    val precipProb: Int,    // % de probabilidad de precipitación
+)
+
+data class DaySlot(
+    val label: String,      // "hoy", "lun", "mar"…
+    val code: Int,
+    val minC: Float,
+    val maxC: Float,
+)
+
+/**
+ * Hora local del lugar (0..24 con fracción), calculada desde el desfase UTC del
+ * pronóstico y el reloj del dispositivo. Se recalcula al vuelo para que el cielo
+ * sea correcto aunque el dato venga de la caché.
+ */
+fun localHourFrom(utcOffsetSeconds: Int): Float {
+    val secs = (((System.currentTimeMillis() / 1000) + utcOffsetSeconds) % 86_400 + 86_400) % 86_400
+    return secs / 3600f
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/data/SkyRepository.kt b/nimbo/src/main/java/com/rizoma/nimbo/data/SkyRepository.kt
new file mode 100644
index 0000000..c3f1778
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/data/SkyRepository.kt
@@ -0,0 +1,165 @@
+package com.rizoma.nimbo.data
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.location.Geocoder
+import android.location.Location
+import android.location.LocationManager
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.net.HttpURLConnection
+import java.net.URL
+import java.time.LocalDate
+import java.util.Locale
+
+/**
+ * Pronóstico local sin dependencias ni clave: ubicación aproximada del sistema
+ * + Open-Meteo sobre HttpURLConnection, con geocoding inverso para el nombre
+ * del lugar y caché para abrir al instante. Todo best-effort: si falta permiso,
+ * ubicación o red, se cae a la caché o devuelve null.
+ */
+class SkyRepository(context: Context) {
+
+    private val app = context.applicationContext
+    private val cache = Cache(app)
+
+    fun hasLocation(): Boolean = ContextCompat.checkSelfPermission(
+        app, Manifest.permission.ACCESS_COARSE_LOCATION,
+    ) == PackageManager.PERMISSION_GRANTED
+
+    /** Pronóstico cacheado (si lo hay), re-parseado para pintar de inmediato. */
+    suspend fun cached(): Forecast? {
+        val s = cache.read() ?: return null
+        return parse(s.json, s.place, s.updatedMs)
+    }
+
+    /** Descarga un pronóstico fresco y lo cachea. Hace E/S: corre en IO. */
+    suspend fun fetch(): Forecast? = withContext(Dispatchers.IO) {
+        val loc = lastLocation() ?: return@withContext null
+        val place = reverseGeocode(loc.latitude, loc.longitude)
+        val body = httpGet(buildUrl(loc.latitude, loc.longitude)) ?: return@withContext null
+        val now = System.currentTimeMillis()
+        val fc = parse(body, place, now) ?: return@withContext null
+        cache.write(body, place, now)
+        fc
+    }
+
+    // --- ubicación -----------------------------------------------------------
+
+    private fun lastLocation(): Location? {
+        if (!hasLocation()) return null
+        val lm = app.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
+        return listOf(
+            LocationManager.NETWORK_PROVIDER,
+            LocationManager.GPS_PROVIDER,
+            LocationManager.PASSIVE_PROVIDER,
+        ).mapNotNull { p ->
+            runCatching {
+                if (lm.isProviderEnabled(p)) lm.getLastKnownLocation(p) else null
+            }.getOrNull()
+        }.maxByOrNull { it.time }
+    }
+
+    @Suppress("DEPRECATION")
+    private fun reverseGeocode(lat: Double, lon: Double): String = runCatching {
+        val a = Geocoder(app, Locale("es")).getFromLocation(lat, lon, 1)?.firstOrNull()
+        a?.locality ?: a?.subAdminArea ?: a?.adminArea ?: a?.countryName
+    }.getOrNull() ?: "tu zona"
+
+    // --- red -----------------------------------------------------------------
+
+    private fun buildUrl(lat: Double, lon: Double): String =
+        "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon" +
+            "&current=temperature_2m,apparent_temperature,relative_humidity_2m," +
+            "weather_code,is_day,wind_speed_10m,precipitation" +
+            "&hourly=temperature_2m,weather_code,precipitation_probability" +
+            "&daily=weather_code,temperature_2m_max,temperature_2m_min" +
+            "&timezone=auto&forecast_days=7"
+
+    private fun httpGet(url: String): String? = runCatching {
+        val conn = (URL(url).openConnection() as HttpURLConnection).apply {
+            connectTimeout = 8_000
+            readTimeout = 8_000
+            requestMethod = "GET"
+        }
+        try {
+            if (conn.responseCode != HttpURLConnection.HTTP_OK) null
+            else conn.inputStream.bufferedReader().use { it.readText() }
+        } finally {
+            conn.disconnect()
+        }
+    }.getOrNull()
+
+    // --- parseo --------------------------------------------------------------
+
+    private fun parse(body: String, place: String, updatedMs: Long): Forecast? = runCatching {
+        val root = JSONObject(body)
+        val offset = root.optInt("utc_offset_seconds", 0)
+
+        val cur = root.getJSONObject("current")
+        val temp = cur.getDouble("temperature_2m").toFloat()
+        val current = Current(
+            code = cur.getInt("weather_code"),
+            temperatureC = temp,
+            apparentC = cur.optDouble("apparent_temperature", temp.toDouble()).toFloat(),
+            isDay = cur.optInt("is_day", 1) == 1,
+            humidity = cur.optInt("relative_humidity_2m", 0),
+            windKmh = cur.optDouble("wind_speed_10m", 0.0).toFloat(),
+            precipMm = cur.optDouble("precipitation", 0.0).toFloat(),
+        )
+        // Hora actual truncada a la hora en punto, para incluir el tramo en curso.
+        val nowHour = cur.getString("time").substring(0, 13) + ":00"
+
+        val h = root.getJSONObject("hourly")
+        val ht = h.getJSONArray("time")
+        val htemp = h.getJSONArray("temperature_2m")
+        val hcode = h.getJSONArray("weather_code")
+        val hprob = h.optJSONArray("precipitation_probability")
+        var start = 0
+        while (start < ht.length() && ht.getString(start) < nowHour) start++
+        val hourly = ArrayList<HourSlot>()
+        var i = start
+        while (i < ht.length() && hourly.size < 24) {
+            val hh = ht.getString(i).substring(11, 13).toInt()
+            hourly.add(
+                HourSlot(
+                    label = if (i == start) "ahora" else hh.toString(),
+                    temperatureC = htemp.getDouble(i).toFloat(),
+                    code = hcode.getInt(i),
+                    precipProb = hprob?.optInt(i, 0) ?: 0,
+                ),
+            )
+            i++
+        }
+
+        val d = root.getJSONObject("daily")
+        val dt = d.getJSONArray("time")
+        val dcode = d.getJSONArray("weather_code")
+        val dmax = d.getJSONArray("temperature_2m_max")
+        val dmin = d.getJSONArray("temperature_2m_min")
+        val daily = ArrayList<DaySlot>()
+        for (k in 0 until dt.length()) {
+            daily.add(
+                DaySlot(
+                    label = if (k == 0) "hoy" else weekdayEs(dt.getString(k)),
+                    code = dcode.getInt(k),
+                    minC = dmin.getDouble(k).toFloat(),
+                    maxC = dmax.getDouble(k).toFloat(),
+                ),
+            )
+        }
+
+        Forecast(place, offset, current, hourly, daily, updatedMs)
+    }.getOrNull()
+
+    private fun weekdayEs(date: String): String =
+        DIAS[LocalDate.parse(date).dayOfWeek.value - 1]
+
+    private companion object {
+        // DayOfWeek.value: lunes = 1 … domingo = 7
+        val DIAS = arrayOf("lun", "mar", "mié", "jue", "vie", "sáb", "dom")
+    }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/data/Weather.kt b/nimbo/src/main/java/com/rizoma/nimbo/data/Weather.kt
new file mode 100644
index 0000000..95b4e16
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/data/Weather.kt
@@ -0,0 +1,51 @@
+package com.rizoma.nimbo.data
+
+/** Familias de cielo en las que agrupamos los códigos WMO de Open-Meteo. */
+enum class Sky { CLEAR, CLOUDY, OVERCAST, FOG, RAIN, SNOW, STORM }
+
+/** Código WMO → familia de cielo (para el fondo y los glifos). */
+fun skyOf(code: Int): Sky = when (code) {
+    0, 1 -> Sky.CLEAR
+    2 -> Sky.CLOUDY
+    3 -> Sky.OVERCAST
+    45, 48 -> Sky.FOG
+    51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82 -> Sky.RAIN
+    71, 73, 75, 77, 85, 86 -> Sky.SNOW
+    95, 96, 99 -> Sky.STORM
+    else -> Sky.CLOUDY
+}
+
+/** Descripción en español, minúscula, del código WMO. */
+fun describir(code: Int): String = when (code) {
+    0 -> "despejado"
+    1 -> "casi despejado"
+    2 -> "parcialmente nublado"
+    3 -> "cubierto"
+    45, 48 -> "niebla"
+    51, 53, 55 -> "llovizna"
+    56, 57 -> "llovizna helada"
+    61, 63 -> "lluvia"
+    65 -> "lluvia fuerte"
+    66, 67 -> "lluvia helada"
+    71, 73 -> "nieve"
+    75 -> "nieve intensa"
+    77 -> "aguanieve"
+    80, 81 -> "chubascos"
+    82 -> "chubascos fuertes"
+    85, 86 -> "chubascos de nieve"
+    95 -> "tormenta"
+    96, 99 -> "tormenta con granizo"
+    else -> "—"
+}
+
+/**
+ * Estado del clima en un instante. [code] es el código WMO; de él derivamos
+ * la familia de [sky]. Lo usa el motor de fondo para teñir el cielo.
+ */
+data class Weather(
+    val code: Int,
+    val temperatureC: Float,
+    val isDay: Boolean,
+) {
+    val sky: Sky get() = skyOf(code)
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/ui/DynamicBackground.kt b/nimbo/src/main/java/com/rizoma/nimbo/ui/DynamicBackground.kt
new file mode 100644
index 0000000..d3b6b42
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/ui/DynamicBackground.kt
@@ -0,0 +1,93 @@
+package com.rizoma.nimbo.ui
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+private data class Blob(
+    val periodMs: Int,
+    val ax: Float, val ay: Float,   // amplitud relativa del vaivén
+    val ox: Float, val oy: Float,   // origen relativo
+    val freqY: Float,               // relación de frecuencia vertical (Lissajous)
+    val phase: Float,
+)
+
+private val blobs = listOf(
+    Blob(49_000, 0.34f, 0.30f, 0.42f, 0.40f, 1.30f, 0f),
+    Blob(67_000, 0.38f, 0.26f, 0.58f, 0.62f, 0.80f, 2.1f),
+    Blob(83_000, 0.30f, 0.34f, 0.50f, 0.30f, 1.70f, 4.0f),
+)
+
+private const val FADE_MS = 4_000
+
+/**
+ * Fondo dinámico: manchas de luz que derivan en trayectorias de Lissajous y
+ * laten suavemente, mezcladas de forma aditiva sobre el vacío. Los colores
+ * vienen de [palette] (hora del día + clima) y transicionan despacio entre
+ * cielos. En Nimbo es la interfaz entera: el clima como pintura ambiente.
+ */
+@Composable
+fun DynamicBackground(modifier: Modifier = Modifier, palette: SkyPalette = DefaultSky) {
+    val transition = rememberInfiniteTransition(label = "fondo")
+    val tau = (2 * PI).toFloat()
+    val p0 by transition.animateFloat(
+        0f, tau, infiniteRepeatable(tween(blobs[0].periodMs, easing = LinearEasing), RepeatMode.Restart), label = "p0",
+    )
+    val p1 by transition.animateFloat(
+        0f, tau, infiniteRepeatable(tween(blobs[1].periodMs, easing = LinearEasing), RepeatMode.Restart), label = "p1",
+    )
+    val p2 by transition.animateFloat(
+        0f, tau, infiniteRepeatable(tween(blobs[2].periodMs, easing = LinearEasing), RepeatMode.Restart), label = "p2",
+    )
+    val phases = floatArrayOf(p0, p1, p2)
+
+    val fade = tween<Color>(FADE_MS, easing = LinearEasing)
+    val base by animateColorAsState(palette.base, fade, label = "base")
+    val cA by animateColorAsState(palette.a, fade, label = "a")
+    val cB by animateColorAsState(palette.b, fade, label = "b")
+    val cC by animateColorAsState(palette.c, fade, label = "c")
+    val intensity by animateFloatAsState(palette.intensity, tween(FADE_MS, easing = LinearEasing), label = "intensity")
+    val colors = listOf(cA, cB, cC)
+
+    Canvas(modifier.fillMaxSize()) {
+        drawRect(base)
+        val w = size.width
+        val h = size.height
+        val span = maxOf(w, h)
+        val alpha = (0.16f * intensity).coerceIn(0f, 1f)
+        blobs.forEachIndexed { i, blob ->
+            val t = phases[i] + blob.phase
+            val cx = w * (blob.ox + blob.ax * cos(t))
+            val cy = h * (blob.oy + blob.ay * sin(t * blob.freqY))
+            val pulse = 0.82f + 0.18f * sin(t * 1.7f)
+            val radius = span * 0.62f * pulse
+            drawCircle(
+                brush = Brush.radialGradient(
+                    colors = listOf(colors[i].copy(alpha = alpha), Color.Transparent),
+                    center = Offset(cx, cy),
+                    radius = radius,
+                ),
+                radius = radius,
+                center = Offset(cx, cy),
+                blendMode = BlendMode.Plus,
+            )
+        }
+    }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/ui/Glyphs.kt b/nimbo/src/main/java/com/rizoma/nimbo/ui/Glyphs.kt
new file mode 100644
index 0000000..df35204
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/ui/Glyphs.kt
@@ -0,0 +1,127 @@
+package com.rizoma.nimbo.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import com.rizoma.nimbo.data.Sky
+import com.rizoma.nimbo.data.skyOf
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+/** Glifo de clima monoline/silueta, dibujado a mano (sin emojis). */
+@Composable
+fun WeatherGlyph(
+    code: Int,
+    isDay: Boolean,
+    modifier: Modifier = Modifier,
+    color: Color = Color.White,
+) {
+    Canvas(modifier) { drawGlyph(skyOf(code), isDay, color) }
+}
+
+private fun DrawScope.drawGlyph(sky: Sky, isDay: Boolean, color: Color) {
+    val u = minOf(size.width, size.height)
+    val cx = size.width / 2f
+    val cy = size.height / 2f
+    val stroke = u * 0.07f
+    val astro = color.copy(alpha = 0.95f)
+
+    fun cloud(ox: Float, oy: Float, w: Float, alpha: Float) {
+        val c = color.copy(alpha = alpha)
+        val r = w * 0.27f
+        drawRoundRect(
+            color = c,
+            topLeft = Offset(ox - w / 2f, oy - r * 0.35f),
+            size = Size(w, r * 1.35f),
+            cornerRadius = CornerRadius(r),
+        )
+        drawCircle(c, r * 0.92f, Offset(ox - w * 0.22f, oy))
+        drawCircle(c, r * 1.18f, Offset(ox + w * 0.02f, oy - r * 0.40f))
+        drawCircle(c, r * 0.86f, Offset(ox + w * 0.28f, oy + r * 0.02f))
+    }
+
+    fun sun(scx: Float, scy: Float, r: Float, rays: Boolean) {
+        drawCircle(astro, r, Offset(scx, scy))
+        if (rays) {
+            for (k in 0 until 8) {
+                val ang = (PI / 4 * k).toFloat()
+                drawLine(
+                    astro,
+                    Offset(scx + cos(ang) * r * 1.45f, scy + sin(ang) * r * 1.45f),
+                    Offset(scx + cos(ang) * r * 1.95f, scy + sin(ang) * r * 1.95f),
+                    strokeWidth = stroke,
+                    cap = StrokeCap.Round,
+                )
+            }
+        }
+    }
+
+    when (sky) {
+        Sky.CLEAR -> {
+            sun(cx, cy, u * 0.20f, rays = isDay)
+            if (!isDay) {
+                // dos estrellas para la noche despejada
+                drawCircle(astro, u * 0.03f, Offset(cx + u * 0.30f, cy - u * 0.26f))
+                drawCircle(astro, u * 0.022f, Offset(cx - u * 0.28f, cy + u * 0.24f))
+            }
+        }
+        Sky.CLOUDY -> {
+            sun(cx - u * 0.16f, cy - u * 0.16f, u * 0.15f, rays = isDay)
+            cloud(cx + u * 0.06f, cy + u * 0.08f, u * 0.66f, 1f)
+        }
+        Sky.OVERCAST -> {
+            cloud(cx - u * 0.10f, cy - u * 0.06f, u * 0.56f, 0.55f)
+            cloud(cx + u * 0.06f, cy + u * 0.10f, u * 0.66f, 1f)
+        }
+        Sky.FOG -> {
+            cloud(cx, cy - u * 0.12f, u * 0.66f, 0.85f)
+            for (k in 0 until 3) {
+                val y = cy + u * (0.16f + k * 0.12f)
+                drawLine(
+                    color.copy(alpha = 0.7f),
+                    Offset(cx - u * 0.26f, y), Offset(cx + u * 0.26f, y),
+                    strokeWidth = stroke * 0.8f, cap = StrokeCap.Round,
+                )
+            }
+        }
+        Sky.RAIN -> {
+            cloud(cx, cy - u * 0.14f, u * 0.66f, 1f)
+            for (k in 0 until 3) {
+                val x = cx + (k - 1) * u * 0.18f
+                drawLine(
+                    color.copy(alpha = 0.85f),
+                    Offset(x, cy + u * 0.16f), Offset(x - u * 0.06f, cy + u * 0.34f),
+                    strokeWidth = stroke * 0.8f, cap = StrokeCap.Round,
+                )
+            }
+        }
+        Sky.SNOW -> {
+            cloud(cx, cy - u * 0.14f, u * 0.66f, 1f)
+            for (k in 0 until 3) {
+                val x = cx + (k - 1) * u * 0.18f
+                drawCircle(color.copy(alpha = 0.85f), u * 0.035f, Offset(x, cy + u * 0.26f))
+            }
+        }
+        Sky.STORM -> {
+            cloud(cx, cy - u * 0.16f, u * 0.66f, 1f)
+            val bolt = Path().apply {
+                moveTo(cx + u * 0.04f, cy + u * 0.04f)
+                lineTo(cx - u * 0.12f, cy + u * 0.22f)
+                lineTo(cx - u * 0.01f, cy + u * 0.22f)
+                lineTo(cx - u * 0.10f, cy + u * 0.42f)
+                lineTo(cx + u * 0.17f, cy + u * 0.16f)
+                lineTo(cx + u * 0.03f, cy + u * 0.16f)
+                close()
+            }
+            drawPath(bolt, com.rizoma.nimbo.ui.theme.NeonRosa)
+        }
+    }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/ui/NimboApp.kt b/nimbo/src/main/java/com/rizoma/nimbo/ui/NimboApp.kt
new file mode 100644
index 0000000..d75d0f2
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/ui/NimboApp.kt
@@ -0,0 +1,315 @@
+package com.rizoma.nimbo.ui
+
+import android.Manifest
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.rizoma.nimbo.NimboViewModel
+import com.rizoma.nimbo.Status
+import com.rizoma.nimbo.data.DaySlot
+import com.rizoma.nimbo.data.Forecast
+import com.rizoma.nimbo.data.HourSlot
+import com.rizoma.nimbo.data.Weather
+import com.rizoma.nimbo.data.describir
+import com.rizoma.nimbo.data.localHourFrom
+import com.rizoma.nimbo.ui.theme.MuyTenue
+import com.rizoma.nimbo.ui.theme.NeonCian
+import com.rizoma.nimbo.ui.theme.Tenue
+import com.rizoma.nimbo.ui.theme.Vacio
+import kotlin.math.roundToInt
+
+@Composable
+fun NimboApp(vm: NimboViewModel = viewModel()) {
+    val launcher = rememberLauncherForActivityResult(
+        ActivityResultContracts.RequestPermission(),
+    ) { vm.onPermission(it) }
+
+    LaunchedEffect(Unit) {
+        if (vm.shouldAskPermission()) launcher.launch(Manifest.permission.ACCESS_COARSE_LOCATION)
+    }
+
+    val forecast = vm.forecast
+    val palette = forecast?.let {
+        skyPaletteFor(
+            localHourFrom(it.utcOffsetSeconds),
+            Weather(it.current.code, it.current.temperatureC, it.current.isDay),
+        )
+    } ?: DefaultSky
+
+    Box(Modifier.fillMaxSize().background(Vacio)) {
+        DynamicBackground(palette = palette)
+        // Velo para legibilidad del texto sobre el cielo en movimiento.
+        Box(
+            Modifier.fillMaxSize().background(
+                Brush.verticalGradient(
+                    listOf(Color.Black.copy(alpha = 0.12f), Color.Transparent, Color.Black.copy(alpha = 0.45f)),
+                ),
+            ),
+        )
+
+        when {
+            forecast != null -> ForecastContent(forecast) { vm.refresh() }
+            vm.status == Status.NeedsPermission -> Centro(
+                "nimbo necesita tu ubicación aproximada para leer el cielo de tu zona",
+                "permitir",
+            ) { launcher.launch(Manifest.permission.ACCESS_COARSE_LOCATION) }
+            vm.status == Status.Error -> Centro(
+                "no pude alcanzar el cielo — revisa tu conexión",
+                "reintentar",
+            ) { vm.refresh() }
+            else -> Box(Modifier.fillMaxSize(), Alignment.Center) {
+                CircularProgressIndicator(color = NeonCian, strokeWidth = 2.dp)
+            }
+        }
+
+        if (vm.refreshing && forecast != null) {
+            CircularProgressIndicator(
+                color = NeonCian,
+                strokeWidth = 2.dp,
+                modifier = Modifier
+                    .align(Alignment.TopCenter)
+                    .windowInsetsPadding(WindowInsets.statusBars)
+                    .padding(top = 10.dp)
+                    .size(18.dp),
+            )
+        }
+    }
+}
+
+@Composable
+private fun ForecastContent(f: Forecast, onRefresh: () -> Unit) {
+    Column(
+        Modifier
+            .fillMaxSize()
+            .verticalScroll(rememberScrollState())
+            .windowInsetsPadding(WindowInsets.systemBars)
+            .padding(horizontal = 24.dp),
+    ) {
+        Spacer(Modifier.height(28.dp))
+
+        // Lugar + actualizado (toca para refrescar).
+        Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable(onClick = onRefresh)) {
+            Text(f.place, color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
+            Text("actualizado ${hace(f.updatedEpochMs)} · toca para refrescar", color = MuyTenue, fontSize = 12.sp)
+        }
+
+        Spacer(Modifier.height(24.dp))
+
+        // Glifo grande + temperatura.
+        Row(verticalAlignment = Alignment.CenterVertically) {
+            WeatherGlyph(
+                f.current.code, f.current.isDay,
+                modifier = Modifier.size(76.dp), color = Color.White,
+            )
+            Spacer(Modifier.width(16.dp))
+            Text(
+                "${f.current.temperatureC.roundToInt()}°",
+                color = Color.White, fontSize = 84.sp, fontWeight = FontWeight.Light,
+            )
+        }
+        Text(describir(f.current.code), color = Tenue, fontSize = 20.sp)
+
+        Spacer(Modifier.height(18.dp))
+
+        // Métricas.
+        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(28.dp)) {
+            Metric("sensación", "${f.current.apparentC.roundToInt()}°")
+            Metric("viento", "${f.current.windKmh.roundToInt()} km/h")
+            Metric("humedad", "${f.current.humidity}%")
+        }
+
+        Spacer(Modifier.height(26.dp))
+        Bloque("por horas") { HourlyStrip(f.hourly) }
+
+        Spacer(Modifier.height(22.dp))
+        Bloque("7 días") { DailyList(f.daily) }
+
+        Spacer(Modifier.height(28.dp))
+    }
+}
+
+@Composable
+private fun Metric(label: String, value: String) {
+    Column {
+        Text(label, color = MuyTenue, fontSize = 11.sp, letterSpacing = 2.sp)
+        Text(value, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium)
+    }
+}
+
+@Composable
+private fun Bloque(titulo: String, content: @Composable () -> Unit) {
+    Surface(color = Color.White.copy(alpha = 0.06f), shape = RoundedCornerShape(20.dp)) {
+        Column(Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
+            Text(
+                titulo,
+                color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp,
+                modifier = Modifier.padding(start = 18.dp, bottom = 12.dp),
+            )
+            content()
+        }
+    }
+}
+
+@Composable
+private fun HourlyStrip(hours: List<HourSlot>) {
+    Row(
+        Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 14.dp),
+        horizontalArrangement = Arrangement.spacedBy(6.dp),
+    ) {
+        hours.forEach { slot ->
+            Column(
+                horizontalAlignment = Alignment.CenterHorizontally,
+                modifier = Modifier.width(46.dp),
+            ) {
+                Text(slot.label, color = Tenue, fontSize = 12.sp, maxLines = 1)
+                Spacer(Modifier.height(8.dp))
+                WeatherGlyph(slot.code, isDay = true, modifier = Modifier.size(26.dp), color = Color.White)
+                Spacer(Modifier.height(8.dp))
+                Text("${slot.temperatureC.roundToInt()}°", color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+                Text(
+                    if (slot.precipProb >= 10) "${slot.precipProb}%" else " ",
+                    color = NeonCian, fontSize = 10.sp,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun DailyList(days: List<DaySlot>) {
+    if (days.isEmpty()) return
+    val weekMin = days.minOf { it.minC }
+    val weekMax = days.maxOf { it.maxC }
+    val span = (weekMax - weekMin).coerceAtLeast(1f)
+
+    Column(Modifier.fillMaxWidth().padding(horizontal = 18.dp)) {
+        days.forEach { d ->
+            Row(
+                Modifier.fillMaxWidth().padding(vertical = 7.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                Text(d.label, color = Tenue, fontSize = 15.sp, modifier = Modifier.width(40.dp))
+                WeatherGlyph(d.code, isDay = true, modifier = Modifier.size(22.dp), color = Color.White)
+                Spacer(Modifier.width(14.dp))
+                Text(
+                    "${d.minC.roundToInt()}°",
+                    color = MuyTenue, fontSize = 14.sp,
+                    textAlign = TextAlign.End, modifier = Modifier.width(30.dp),
+                )
+                Spacer(Modifier.width(10.dp))
+                TempBar(
+                    startFrac = (d.minC - weekMin) / span,
+                    endFrac = (d.maxC - weekMin) / span,
+                    minC = d.minC, maxC = d.maxC,
+                    modifier = Modifier.weight(1f).height(6.dp),
+                )
+                Spacer(Modifier.width(10.dp))
+                Text(
+                    "${d.maxC.roundToInt()}°",
+                    color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium,
+                    modifier = Modifier.width(30.dp),
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun TempBar(startFrac: Float, endFrac: Float, minC: Float, maxC: Float, modifier: Modifier) {
+    androidx.compose.foundation.Canvas(modifier) {
+        val y = size.height / 2f
+        drawLine(
+            Color.White.copy(alpha = 0.12f),
+            Offset(0f, y), Offset(size.width, y),
+            strokeWidth = size.height, cap = StrokeCap.Round,
+        )
+        val x0 = size.width * startFrac.coerceIn(0f, 1f)
+        // Asegura ancho mínimo (un punto) para no degenerar el gradiente.
+        val x1 = (size.width * endFrac.coerceIn(0f, 1f)).coerceAtLeast(x0 + size.height)
+        drawLine(
+            brush = Brush.horizontalGradient(
+                listOf(colorForTemp(minC), colorForTemp(maxC)),
+                startX = x0, endX = x1,
+            ),
+            start = Offset(x0, y), end = Offset(x1, y),
+            strokeWidth = size.height, cap = StrokeCap.Round,
+        )
+    }
+}
+
+@Composable
+private fun Centro(mensaje: String, accion: String, onClick: () -> Unit) {
+    Box(Modifier.fillMaxSize().padding(40.dp), Alignment.Center) {
+        Column(horizontalAlignment = Alignment.CenterHorizontally) {
+            Text(mensaje, color = Tenue, fontSize = 17.sp, textAlign = TextAlign.Center)
+            Spacer(Modifier.height(20.dp))
+            Surface(
+                color = NeonCian, shape = RoundedCornerShape(50),
+                modifier = Modifier.clip(RoundedCornerShape(50)).clickable(onClick = onClick),
+            ) {
+                Text(
+                    accion, color = Vacio, fontSize = 15.sp, fontWeight = FontWeight.Bold,
+                    modifier = Modifier.padding(horizontal = 22.dp, vertical = 11.dp),
+                )
+            }
+        }
+    }
+}
+
+// Color frío→cálido según temperatura (°C), para las barras de los días.
+private fun colorForTemp(c: Float): Color {
+    val t = ((c + 5f) / 40f).coerceIn(0f, 1f) // -5..35 °C
+    val frio = Color(0xFF3AA0FF)
+    val templado = Color(0xFF7CFF4F)
+    val calido = Color(0xFFFF6B3A)
+    return if (t < 0.5f) lerp(frio, templado, t * 2f) else lerp(templado, calido, (t - 0.5f) * 2f)
+}
+
+private fun hace(ms: Long): String {
+    if (ms <= 0L) return "hace un momento"
+    val s = (System.currentTimeMillis() - ms) / 1000
+    return when {
+        s < 60 -> "hace un momento"
+        s < 3600 -> "hace ${s / 60} min"
+        s < 86_400 -> "hace ${s / 3600} h"
+        else -> "hace ${s / 86_400} d"
+    }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/ui/Sky.kt b/nimbo/src/main/java/com/rizoma/nimbo/ui/Sky.kt
new file mode 100644
index 0000000..6ce3063
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/ui/Sky.kt
@@ -0,0 +1,103 @@
+package com.rizoma.nimbo.ui
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.lerp
+import com.rizoma.nimbo.data.Sky
+import com.rizoma.nimbo.data.Weather
+import com.rizoma.nimbo.ui.theme.NeonCian
+import com.rizoma.nimbo.ui.theme.NeonRosa
+import com.rizoma.nimbo.ui.theme.NeonVerde
+
+/**
+ * El "cielo" del fondo: un color base (el vacío teñido) y tres manchas de luz,
+ * más una [intensity] global que el clima atenúa o aviva.
+ */
+data class SkyPalette(
+    val base: Color,
+    val a: Color,
+    val b: Color,
+    val c: Color,
+    val intensity: Float,
+)
+
+/** Mediodía neón del rizoma: el fondo por defecto sin hora ni clima resueltos. */
+val DefaultSky = SkyPalette(Color(0xFF050507), NeonVerde, NeonRosa, NeonCian, 1f)
+
+/**
+ * Paleta del cielo para una hora (0..24, con fracción) y, si lo hay, el clima.
+ * La hora marca el tono base; el clima lo modula (despejado aviva, nublado apaga,
+ * lluvia enfría hacia azul, nieve aclara, tormenta tira a violeta).
+ */
+fun skyPaletteFor(hour: Float, weather: Weather?): SkyPalette {
+    val base = basePalette(hour)
+    val w = weather ?: return base
+    return when (w.sky) {
+        Sky.CLEAR -> base.copy(intensity = base.intensity * 1.08f)
+        Sky.CLOUDY -> base.desaturate(0.25f, 0.85f)
+        Sky.OVERCAST -> base.desaturate(0.45f, 0.65f)
+        Sky.FOG -> base.desaturate(0.60f, 0.55f)
+            .copy(base = lerp(base.base, Color(0xFF20242B), 0.40f))
+        Sky.RAIN -> base.shift(LLUVIA, 0.40f, 0.70f)
+        Sky.SNOW -> base.shift(NIEVE, 0.45f, 0.85f)
+            .copy(base = lerp(base.base, Color(0xFF10161F), 0.50f))
+        Sky.STORM -> base.shift(TORMENTA, 0.45f, 0.70f)
+    }
+}
+
+private class Anchor(val hour: Float, val palette: SkyPalette)
+
+// Anclas a lo largo del día; entre ellas se interpola (con vuelta a medianoche).
+private val anchors = listOf(
+    Anchor(2f, SkyPalette(Color(0xFF05060E), NeonCian, Color(0xFF6A4CFF), Color(0xFF1E3A8A), 0.70f)),
+    Anchor(6f, SkyPalette(Color(0xFF0E0810), Color(0xFFFF7E5F), NeonRosa, Color(0xFFFFC36B), 0.90f)),
+    Anchor(12f, SkyPalette(Color(0xFF050507), NeonVerde, NeonRosa, NeonCian, 1.00f)),
+    Anchor(18f, SkyPalette(Color(0xFF0C060A), Color(0xFFFF5E3A), Color(0xFFB14CFF), NeonRosa, 0.95f)),
+    Anchor(22f, SkyPalette(Color(0xFF07060E), Color(0xFF6A4CFF), Color(0xFF1E3A8A), NeonCian, 0.75f)),
+)
+
+private val GRIS = Color(0xFF8A8F99)
+private val LLUVIA = Color(0xFF2A6FB0)
+private val NIEVE = Color(0xFFCFE8FF)
+private val TORMENTA = Color(0xFF7A3CFF)
+
+private fun basePalette(hour: Float): SkyPalette {
+    val h = ((hour % 24f) + 24f) % 24f
+    for (i in anchors.indices) {
+        val cur = anchors[i]
+        val nxt = anchors[(i + 1) % anchors.size]
+        val start = cur.hour
+        var end = nxt.hour
+        var hh = h
+        if (end <= start) {           // tramo que cruza medianoche (22 → 2)
+            end += 24f
+            if (hh < start) hh += 24f
+        }
+        if (hh in start..end) {
+            val t = ((hh - start) / (end - start)).coerceIn(0f, 1f)
+            return lerpPalette(cur.palette, nxt.palette, t)
+        }
+    }
+    return anchors[0].palette
+}
+
+private fun lerpPalette(x: SkyPalette, y: SkyPalette, t: Float) = SkyPalette(
+    base = lerp(x.base, y.base, t),
+    a = lerp(x.a, y.a, t),
+    b = lerp(x.b, y.b, t),
+    c = lerp(x.c, y.c, t),
+    intensity = x.intensity + (y.intensity - x.intensity) * t,
+)
+
+private fun SkyPalette.desaturate(amount: Float, intensityMul: Float) = copy(
+    a = lerp(a, GRIS, amount),
+    b = lerp(b, GRIS, amount),
+    c = lerp(c, GRIS, amount),
+    intensity = intensity * intensityMul,
+)
+
+private fun SkyPalette.shift(toward: Color, amount: Float, intensityMul: Float) = copy(
+    a = lerp(a, toward, amount),
+    b = lerp(b, toward, amount * 0.7f),
+    c = lerp(c, toward, amount),
+    intensity = intensity * intensityMul,
+)
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/ui/theme/Theme.kt b/nimbo/src/main/java/com/rizoma/nimbo/ui/theme/Theme.kt
new file mode 100644
index 0000000..0412036
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/ui/theme/Theme.kt
@@ -0,0 +1,36 @@
+package com.rizoma.nimbo.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+// Paleta del rizoma: neón sobre vacío (compartida con Órbita/Trazo/Consola/Matiz).
+val Vacio = Color(0xFF050507)
+val Superficie = Color(0xFF0C0C12)
+val SuperficieAlta = Color(0xFF14141C)
+val NeonVerde = Color(0xFF7CFF4F)
+val NeonRosa = Color(0xFFFF2E88)
+val NeonCian = Color(0xFF00E5FF)
+val Tenue = Color(0xB3FFFFFF)
+val MuyTenue = Color(0x66FFFFFF)
+
+private val EsquemaNimbo = darkColorScheme(
+    primary = NeonCian,
+    onPrimary = Vacio,
+    secondary = NeonRosa,
+    onSecondary = Vacio,
+    tertiary = NeonVerde,
+    onTertiary = Vacio,
+    background = Vacio,
+    onBackground = Color.White,
+    surface = Superficie,
+    onSurface = Color.White,
+    surfaceVariant = SuperficieAlta,
+    onSurfaceVariant = Tenue,
+)
+
+@Composable
+fun NimboTheme(content: @Composable () -> Unit) {
+    MaterialTheme(colorScheme = EsquemaNimbo, content = content)
+}
diff --git a/nimbo/src/main/res/drawable/ic_launcher_fg.xml b/nimbo/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..bf725e7
--- /dev/null
+++ b/nimbo/src/main/res/drawable/ic_launcher_fg.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <!-- sol asomando -->
+    <path
+        android:pathData="M31 40 a11 11 0 1 0 22 0 a11 11 0 1 0 -22 0"
+        android:strokeColor="#00E5FF"
+        android:strokeWidth="3" />
+    <!-- nube -->
+    <path android:fillColor="#DCE6F2" android:pathData="M34 62 a12 12 0 1 0 24 0 a12 12 0 1 0 -24 0" />
+    <path android:fillColor="#DCE6F2" android:pathData="M47 57 a15 15 0 1 0 30 0 a15 15 0 1 0 -30 0" />
+    <path android:fillColor="#DCE6F2" android:pathData="M62 63 a11 11 0 1 0 22 0 a11 11 0 1 0 -22 0" />
+    <path android:fillColor="#DCE6F2" android:pathData="M40 60 h38 v12 h-38 z" />
+    <!-- lluvia -->
+    <path
+        android:pathData="M50 78 l-4 9 M64 78 l-4 9"
+        android:strokeColor="#FF2E88"
+        android:strokeWidth="3"
+        android:strokeLineCap="round" />
+</vector>
diff --git a/nimbo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/nimbo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..088fcce
--- /dev/null
+++ b/nimbo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_bg" />
+    <foreground android:drawable="@drawable/ic_launcher_fg" />
+</adaptive-icon>
diff --git a/nimbo/src/main/res/values/colors.xml b/nimbo/src/main/res/values/colors.xml
new file mode 100644
index 0000000..917256d
--- /dev/null
+++ b/nimbo/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="ic_bg">#050507</color>
+</resources>
diff --git a/nimbo/src/main/res/values/strings.xml b/nimbo/src/main/res/values/strings.xml
new file mode 100644
index 0000000..425cdde
--- /dev/null
+++ b/nimbo/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">nimbo</string>
+</resources>
diff --git a/nimbo/src/main/res/values/themes.xml b/nimbo/src/main/res/values/themes.xml
new file mode 100644
index 0000000..7b917c9
--- /dev/null
+++ b/nimbo/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="Theme.Nimbo" parent="android:Theme.Material.NoActionBar">
+        <item name="android:windowBackground">#050507</item>
+        <item name="android:statusBarColor">@android:color/transparent</item>
+        <item name="android:navigationBarColor">@android:color/transparent</item>
+        <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
+    </style>
+</resources>
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 6c08155..17ccf4b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -24,3 +24,4 @@ include(":launcher")
 include(":keyboard")
 include(":consola")
 include(":matiz")
+include(":nimbo")