gioser

Órbita: núcleo vivo, ronda sin saltos, orden de búsqueda y marca gioser.net

El centro del átomo ahora deriva de color por la paleta entera (verde →
cian → violeta → rosa → ámbar) en 15 minutos: nunca se ve cambiar, solo
se nota que el color de hace un rato ya no es el de ahora. Los anillos se
tiñen con él. Y brilla más: núcleo blanco pequeño envuelto en un halo muy
amplio y difuminado (0.58 del lado menor) que alcanza más allá del primer
anillo y respira despacio.

La ronda ya no salta. Dos causas y dos arreglos:

- La posición se leía en composición (`spin` con `by`) → cada fotograma
  recomponía todos los nodos y, al saltarse recomposiciones, el icono
  reaparecía de golpe en otro punto. Ahora el ángulo se calcula dentro de
  `graphicsLayer`: fase de dibujo, sin recomponer.
- El reparto se recalculaba con cada cambio de calor, así que abrir una
  app reordenaba el átomo a tus espaldas. Ahora las casillas se congelan y
  solo se recalculan cuando cambia la firma del átomo (qué apps hay,
  fijadas, alejadas); el calor sube sin mover nada. Y aun entonces cada
  icono viaja a su casilla con un muelle lento (ángulo desenrollado al
  camino corto), nunca de golpe.

Para que "alejar" siga notándose, AppNode lleva ahora su `penalty` y entra
en la firma; el reparto trabaja sobre claves (Slot por clave), así que los
iconos que se recargan no arrastran posiciones viejas.

Búsqueda: fila de criterios —frecuencia (por defecto, el orden del rizoma),
nombre y recientes (fecha de instalación)—, recordado en el DataStore.

Marca de la casa: "gioser.net" discreto bajo los puntos de página y al pie
de la búsqueda, más README del módulo listo para publicar.

Compilado aquí con kotlinc 2.2 + plugin de Compose contra los jars de la
caché (no hay JDK 21 para Gradle en este equipo): 0 errores.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016w3QXjA1GHKcJG1sfJAbco

f01b7a1aa91e314295debf67c22ce5803495b2de · por Sergio · (hace 1 mes) · padre 1aeaadc90 · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

10 archivos · +386 −74

diff --git a/launcher/README.md b/launcher/README.md
new file mode 100644
index 0000000..b82621b
--- /dev/null
+++ b/launcher/README.md
@@ -0,0 +1,67 @@
+# Órbita — el launcher del rizoma
+
+Un launcher de Android (`com.rizoma.orbita`) sin rejilla de iconos: las apps
+orbitan un núcleo de luz. Cuanto más se usa una app, más adentro cae su
+órbita; lo fijado no se suelta del centro; lo que estorba se expulsa del
+átomo sin desinstalarlo.
+
+Hecho en **[gioser.net](https://gioser.net)** — parte del rizoma
+(*Órbita · Consola · Matiz · Nimbo · Notas · Cubo · Nonet · Vela*).
+
+## La cinta de cinco páginas
+
+Se arrastra en horizontal; el átomo es una sola pieza que se desliza detrás,
+de ahí la sensación de continuidad. «Atrás» siempre vuelve al centro.
+
+| 0 clima | 1 nuevas | **2 átomo** | 3 expulsadas | 4 desterradas |
+|---|---|---|---|---|
+| hora + cielo local | recién instaladas | la ronda de órbitas | echadas hace poco, aún alborotadas | echadas hace tiempo, ya quietas |
+
+## El núcleo
+
+- **Brilla**: un punto blanco envuelto en un halo muy amplio y difuminado,
+  que alcanza más allá del primer anillo y respira despacio.
+- **Deriva de color**: recorre la paleta entera (verde → cian → violeta →
+  rosa → ámbar) en 15 minutos. Nunca se ve cambiar; solo se nota que el
+  color de hace un rato ya no es el de ahora. Los anillos se tiñen con él.
+- **Se toca para buscar.**
+
+## La ronda no salta
+
+Dos reglas sostienen la órbita:
+
+1. **Las casillas se congelan.** El reparto solo se recalcula cuando cambia
+   la firma del átomo —qué apps hay, cuáles están fijadas, cuáles alejadas—.
+   Usar una app le sube el calor, pero no mueve nada a tus espaldas: el
+   átomo se reordena cuando *tú* actúas sobre él. Y aun entonces, cada icono
+   viaja a su nueva casilla con un muelle lento.
+2. **La vuelta se calcula al dibujar** (`graphicsLayer`), no recomponiendo:
+   va a 60 fps sin depender de que Compose recomponga cada fotograma.
+
+## Buscar
+
+Un toque en el núcleo. La lista se ordena por **frecuencia** (por defecto,
+el orden del rizoma: fijados primero, luego el calor), por **nombre** o por
+**recientes** (fecha de instalación). El criterio se recuerda.
+
+## Gestos
+
+- Toque: abrir. Toque largo: menú (fijar · alejar · quitar del átomo · info
+  · desinstalar).
+- Arrastre horizontal: cambiar de página. Atrás: volver al centro.
+
+## Permisos
+
+`INTERNET` + `ACCESS_COARSE_LOCATION` solo para el clima de la página 0, y
+se piden **al acercarse a esa página**, no al arrancar.
+`REQUEST_DELETE_PACKAGES` para el «desinstalar» del menú.
+
+## Compilar
+
+```sh
+./gradlew :launcher:assembleDebug   # requiere JDK 21 y el SDK de Android
+```
+
+---
+
+Autoría: Sergio (gioser.net), en co-autoría con Claude (Anthropic).
diff --git a/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt b/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
index 0c30323..b940470 100644
--- a/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.edit
 import androidx.datastore.preferences.core.intPreferencesKey
 import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
 import androidx.datastore.preferences.core.stringSetPreferencesKey
 import androidx.datastore.preferences.preferencesDataStore
 import kotlinx.coroutines.flow.Flow
@@ -80,6 +81,13 @@ class UsageStore(context: Context) {
         }
     }
 
+    /** Criterio de orden elegido en la búsqueda (se recuerda entre sesiones). */
+    fun sortMode(): Flow<String?> = store.data.map { it[SORT] }
+
+    suspend fun setSortMode(mode: String) {
+        store.edit { prefs -> prefs[SORT] = mode }
+    }
+
     suspend fun record(nodeKey: String) {
         store.edit { prefs ->
             val k = intPreferencesKey(COUNT_PREFIX + nodeKey)
@@ -132,5 +140,6 @@ class UsageStore(context: Context) {
         const val PUSH_BUFFER = 8
         val PINNED = stringSetPreferencesKey("pinned")
         val HIDDEN = stringSetPreferencesKey("hidden")
+        val SORT = stringPreferencesKey("search_sort")
     }
 }
diff --git a/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt b/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
index 67a266a..dbe38a0 100644
--- a/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
@@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.ImageBitmap
  * [firstInstallTime] (epoch ms) detecta apps recién instaladas ("nuevas").
  * [hiddenAt] (epoch ms) marca cuándo se expulsó del átomo, para que las
  * expulsadas se vayan calmando con el tiempo hasta quedar desterradas.
+ * [penalty] es el lastre de "alejar": resta calor a la hora de repartir órbitas.
  */
 data class AppNode(
     val packageName: String,
@@ -16,6 +17,7 @@ data class AppNode(
     val icon: ImageBitmap,
     val score: Float = 0f,
     val pinned: Boolean = false,
+    val penalty: Int = 0,
     val hidden: Boolean = false,
     val firstInstallTime: Long = 0L,
     val hiddenAt: Long = 0L,
diff --git a/launcher/src/main/java/com/rizoma/orbita/model/SearchSort.kt b/launcher/src/main/java/com/rizoma/orbita/model/SearchSort.kt
new file mode 100644
index 0000000..0ff420a
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/model/SearchSort.kt
@@ -0,0 +1,18 @@
+package com.rizoma.orbita.model
+
+/** Criterio de orden de la búsqueda. El del rizoma —la frecuencia— es el de casa. */
+enum class SearchSort {
+    /** Calor de uso: lo más lanzado arriba (por defecto). */
+    FRECUENCIA,
+
+    /** Alfabético por etiqueta. */
+    NOMBRE,
+
+    /** Lo instalado hace menos tiempo, arriba. */
+    RECIENTE;
+
+    companion object {
+        fun from(name: String?): SearchSort =
+            entries.firstOrNull { it.name == name } ?: FRECUENCIA
+    }
+}
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
index a9da2d4..f9dc0a3 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.fillMaxSize
@@ -63,6 +64,7 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
@@ -71,6 +73,7 @@ import androidx.core.content.ContextCompat
 import androidx.lifecycle.viewmodel.compose.viewModel
 import com.rizoma.orbita.R
 import com.rizoma.orbita.model.AppNode
+import com.rizoma.orbita.model.SearchSort
 import com.rizoma.orbita.ui.theme.NeonCian
 import com.rizoma.orbita.ui.theme.NeonRosa
 import com.rizoma.orbita.ui.theme.NeonVerde
@@ -94,6 +97,7 @@ fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
     val weather by vm.weather.collectAsState()
 
     var searchOpen by remember { mutableStateOf(false) }
+    val searchSort by vm.searchSort.collectAsState()
     var menuNode by remember { mutableStateOf<AppNode?>(null) }
 
     val context = LocalContext.current
@@ -190,18 +194,24 @@ fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
                 }
             }
 
-            PageDots(
-                progress = { pos },
+            Column(
                 modifier = Modifier
                     .align(Alignment.BottomCenter)
                     .windowInsetsPadding(WindowInsets.systemBars)
-                    .padding(bottom = 22.dp),
-            )
+                    .padding(bottom = 14.dp),
+                horizontalAlignment = Alignment.CenterHorizontally,
+            ) {
+                PageDots(progress = { pos })
+                Spacer(Modifier.height(10.dp))
+                Marca()
+            }
         }
 
         if (searchOpen) {
             SearchOverlay(
                 nodes = allNodes,
+                sort = searchSort,
+                onSort = vm::setSearchSort,
                 onLaunch = {
                     vm.launch(it)
                     searchOpen = false
@@ -229,14 +239,25 @@ fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
 @Composable
 private fun SearchOverlay(
     nodes: List<AppNode>,
+    sort: SearchSort,
+    onSort: (SearchSort) -> Unit,
     onLaunch: (AppNode) -> Unit,
     onLongPress: (AppNode) -> Unit,
     onClose: () -> Unit,
 ) {
     var query by remember { mutableStateOf("") }
-    val filtered = remember(query, nodes) {
-        if (query.isBlank()) nodes
-        else nodes.filter { it.label.contains(query.trim(), ignoreCase = true) }
+    val filtered = remember(query, nodes, sort) {
+        val hits =
+            if (query.isBlank()) nodes
+            else nodes.filter { it.label.contains(query.trim(), ignoreCase = true) }
+        when (sort) {
+            // `nodes` ya viene ordenado por calor (fijados primero): es el orden de casa.
+            SearchSort.FRECUENCIA -> hits
+            SearchSort.NOMBRE -> hits.sortedBy { it.label.lowercase() }
+            SearchSort.RECIENTE -> hits.sortedWith(
+                compareByDescending<AppNode> { it.firstInstallTime }.thenBy { it.label.lowercase() }
+            )
+        }
     }
     val focus = remember { FocusRequester() }
     LaunchedEffect(Unit) { focus.requestFocus() }
@@ -255,8 +276,9 @@ private fun SearchOverlay(
         Column(
             Modifier
                 .fillMaxSize()
+                .windowInsetsPadding(WindowInsets.systemBars)
                 .padding(horizontal = 28.dp)
-                .padding(top = 96.dp)
+                .padding(top = 64.dp)
         ) {
             BasicTextField(
                 value = query,
@@ -280,9 +302,23 @@ private fun SearchOverlay(
             )
             Spacer(Modifier.height(4.dp))
             Box(Modifier.fillMaxWidth().height(1.dp).background(NeonVerde.copy(alpha = 0.4f)))
-            Spacer(Modifier.height(16.dp))
+            Spacer(Modifier.height(14.dp))
+
+            // Criterio de orden: frecuencia (el del rizoma), nombre o recién instaladas.
+            Row(Modifier.fillMaxWidth()) {
+                OrdenChip(R.string.sort_frequency, sort == SearchSort.FRECUENCIA, NeonVerde) {
+                    onSort(SearchSort.FRECUENCIA)
+                }
+                OrdenChip(R.string.sort_name, sort == SearchSort.NOMBRE, NeonCian) {
+                    onSort(SearchSort.NOMBRE)
+                }
+                OrdenChip(R.string.sort_recent, sort == SearchSort.RECIENTE, NeonRosa) {
+                    onSort(SearchSort.RECIENTE)
+                }
+            }
+            Spacer(Modifier.height(12.dp))
 
-            LazyColumn(Modifier.fillMaxSize()) {
+            LazyColumn(Modifier.fillMaxWidth().weight(1f)) {
                 items(filtered, key = { it.key }) { node ->
                     // Los expulsados se ven atenuados; un toque largo abre su menú (para devolverlos).
                     val dim = if (node.hidden) 0.4f else 1f
@@ -321,10 +357,43 @@ private fun SearchOverlay(
                     }
                 }
             }
+            Marca(Modifier.align(Alignment.CenterHorizontally).padding(vertical = 14.dp))
         }
     }
 }
 
+/** Un criterio de orden: encendido lleva su color; apagado, apenas se insinúa. */
+@Composable
+private fun RowScope.OrdenChip(labelRes: Int, selected: Boolean, accent: Color, onClick: () -> Unit) {
+    Text(
+        text = stringResource(labelRes),
+        color = if (selected) accent else Tenue.copy(alpha = 0.45f),
+        fontSize = 12.sp,
+        fontWeight = if (selected) FontWeight.Medium else FontWeight.Light,
+        maxLines = 1,
+        textAlign = TextAlign.Center,
+        modifier = Modifier
+            .weight(1f)
+            .clip(RoundedCornerShape(20.dp))
+            .background(if (selected) accent.copy(alpha = 0.12f) else Color.Transparent)
+            .clickable(onClick = onClick)
+            .padding(vertical = 7.dp),
+    )
+}
+
+/** Marca de la casa: discreta, del tamaño de una firma. */
+@Composable
+internal fun Marca(modifier: Modifier = Modifier) {
+    Text(
+        text = stringResource(R.string.brand),
+        color = Color.White.copy(alpha = 0.22f),
+        fontSize = 9.sp,
+        letterSpacing = 3.sp,
+        fontWeight = FontWeight.Light,
+        modifier = modifier,
+    )
+}
+
 @Composable
 private fun NodeMenu(
     node: AppNode,
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
index 289a91f..45e4ce6 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
@@ -2,69 +2,87 @@ package com.rizoma.orbita.ui
 
 import androidx.compose.animation.core.LinearEasing
 import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.Spring
 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.spring
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
+import androidx.compose.runtime.State
+import androidx.compose.runtime.key
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.scale
+import androidx.compose.ui.draw.drawBehind
 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 androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.lerp
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import com.rizoma.orbita.model.AppNode
 import com.rizoma.orbita.ui.theme.NeonCian
 import com.rizoma.orbita.ui.theme.NeonRosa
 import com.rizoma.orbita.ui.theme.NeonVerde
+import com.rizoma.orbita.ui.theme.NucleoPaleta
 import com.rizoma.orbita.ui.theme.Tenue
 import kotlin.math.PI
 import kotlin.math.cos
 import kotlin.math.floor
 import kotlin.math.max
 import kotlin.math.min
-import kotlin.math.roundToInt
 import kotlin.math.sin
 
-private data class Placement(
-    val node: AppNode,
+/** La casilla orbital de un nodo: en qué anillo cae y con qué ángulo de partida. */
+private data class Slot(
     val ring: Int,
-    val angle: Double,
+    val angle: Float,
     val radius: Float,
 )
 
+/** El reparto entero: una casilla por clave de nodo, más cuántos anillos ocupa. */
+private data class Orbitas(val slots: Map<String, Slot>, val rings: Int)
+
 private val ringColors = listOf(NeonVerde, NeonRosa, NeonCian)
+private const val TAU_F = (2 * PI).toFloat()
+
+/** Una vuelta completa del tinte del núcleo: tan larga que el cambio no se percibe. */
+private const val TINTE_MS = 900_000
 
 /**
  * Distribución anti-rejilla: los nodos caen en órbitas concéntricas según su calor.
  * Las órbitas internas (más cerca del centro) son las apps más usadas y las fijadas.
+ *
+ * Dos reglas mantienen la ronda estable (nada de saltos):
+ *  - el reparto en casillas se congela: el orden por calor decide la primera vez,
+ *    y a partir de ahí un nodo conserva su casilla aunque su calor cambie al usarlo;
+ *  - la posición se calcula en la fase de dibujo (graphicsLayer), no recomponiendo:
+ *    la vuelta va a 60 fps sin depender de que Compose recomponga cada fotograma.
  */
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
@@ -77,12 +95,27 @@ fun OrbitLayout(
 ) {
     val density = LocalDensity.current
     val transition = rememberInfiniteTransition(label = "orbita")
-    val spin by transition.animateFloat(
+    // Sin `by`: el State se lee dentro de lambdas de dibujo, así el giro no recompone.
+    val spin = transition.animateFloat(
         initialValue = 0f,
-        targetValue = (2 * PI).toFloat(),
+        targetValue = TAU_F,
         animationSpec = infiniteRepeatable(tween(180_000, easing = LinearEasing), RepeatMode.Restart),
         label = "spin",
     )
+    val pulse = transition.animateFloat(
+        initialValue = 0.94f,
+        targetValue = 1.06f,
+        animationSpec = infiniteRepeatable(tween(5_200, easing = LinearEasing), RepeatMode.Reverse),
+        label = "pulso",
+    )
+    // Deriva de color del núcleo: recorre la paleta entera en 15 minutos.
+    val tinte = transition.animateFloat(
+        initialValue = 0f,
+        targetValue = NucleoPaleta.size.toFloat(),
+        animationSpec = infiniteRepeatable(tween(TINTE_MS, easing = LinearEasing), RepeatMode.Restart),
+        label = "tinte",
+    )
+    val glow: () -> Color = { colorDelCiclo(tinte.value) }
 
     BoxWithConstraints(modifier.fillMaxSize()) {
         val wPx = with(density) { maxWidth.toPx() }
@@ -96,25 +129,32 @@ fun OrbitLayout(
         val colHalf = with(density) { 32.dp.toPx() }
         val iconHalf = with(density) { 27.dp.toPx() }
 
-        val placements = remember(nodes, wPx, hPx) {
-            computePlacements(nodes, firstR, ringGap, spacing)
-        }
-        val rings = (placements.maxOfOrNull { it.ring } ?: -1) + 1
+        val orbitas = rememberOrbitas(nodes, firstR, ringGap, spacing)
+        val rings = orbitas.rings
 
-        // Halo ambiental + anillos orbitales.
+        // Halo del núcleo (mucho alcance, muy difuminado) + anillos orbitales.
         Canvas(Modifier.fillMaxSize()) {
+            val c = glow()
+            val p = pulse.value
+            val halo = min(wPx, hPx) * 0.58f * p
             drawCircle(
                 brush = Brush.radialGradient(
-                    colors = listOf(NeonVerde.copy(alpha = 0.06f), androidx.compose.ui.graphics.Color.Transparent),
+                    0.00f to c.copy(alpha = 0.22f),
+                    0.16f to c.copy(alpha = 0.115f),
+                    0.38f to c.copy(alpha = 0.055f),
+                    0.66f to c.copy(alpha = 0.018f),
+                    1.00f to Color.Transparent,
                     center = Offset(cx, cy),
-                    radius = maxOf(wPx, hPx) * 0.6f,
+                    radius = halo,
                 ),
-                radius = maxOf(wPx, hPx) * 0.6f,
+                radius = halo,
                 center = Offset(cx, cy),
+                blendMode = BlendMode.Plus,
             )
             for (r in 0 until rings) {
+                // Los anillos se tiñen a medias con el color vivo del núcleo.
                 drawCircle(
-                    color = ringColors[r % ringColors.size].copy(alpha = 0.12f),
+                    color = lerp(ringColors[r % ringColors.size], c, 0.5f).copy(alpha = 0.14f),
                     radius = firstR + r * ringGap,
                     center = Offset(cx, cy),
                     style = Stroke(width = 1.2f),
@@ -125,26 +165,93 @@ fun OrbitLayout(
         // Núcleo: toque para buscar en el rizoma.
         CenterHub(
             modifier = Modifier.align(Alignment.Center),
+            glow = glow,
+            pulse = pulse,
             onTap = onCenterTap,
         )
 
-        // Nodos en órbita.
-        placements.forEach { p ->
-            // Anillos contiguos contrarrotan. Factor entero (±1) → el bucle es continuo.
-            val dir = if (p.ring % 2 == 0) 1f else -1f
-            val angle = p.angle + spin * dir
-            val x = cx + p.radius * cos(angle).toFloat() - colHalf
-            val y = cy + p.radius * sin(angle).toFloat() - iconHalf
-            NodeItem(
-                node = p.node,
-                modifier = Modifier.offset { IntOffset(x.roundToInt(), y.roundToInt()) },
-                onLaunch = onLaunch,
-                onLongPress = onLongPress,
-            )
+        // Nodos en órbita. Si una casilla cambia (se instala o se expulsa una app),
+        // el icono viaja hasta ella con un muelle lento: nunca reaparece en otro sitio.
+        nodes.forEach { node ->
+            val slot = orbitas.slots[node.key] ?: return@forEach
+            key(node.key) {
+                val angle = animateFloatAsState(
+                    targetValue = slot.angle,
+                    animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessVeryLow),
+                    label = "ang",
+                )
+                val radius = animateFloatAsState(
+                    targetValue = slot.radius,
+                    animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessVeryLow),
+                    label = "rad",
+                )
+                // Anillos contiguos contrarrotan. Factor entero (±1) → el bucle es continuo.
+                val dir = if (slot.ring % 2 == 0) 1f else -1f
+                NodeItem(
+                    node = node,
+                    modifier = Modifier
+                        .align(Alignment.TopStart)
+                        .graphicsLayer {
+                            val a = angle.value + spin.value * dir
+                            translationX = cx + radius.value * cos(a) - colHalf
+                            translationY = cy + radius.value * sin(a) - iconHalf
+                        },
+                    onLaunch = onLaunch,
+                    onLongPress = onLongPress,
+                )
+            }
         }
     }
 }
 
+/**
+ * Casillas estables. El orden solo se recalcula cuando cambia la *firma* del átomo:
+ * qué apps hay, cuáles están fijadas y cuáles alejadas. El calor sube al usar una app,
+ * pero no mueve nada por su cuenta — el átomo se reordena cuando tú actúas sobre él,
+ * nunca a tus espaldas mientras miras la ronda. Y aun entonces, cada icono viaja a su
+ * nueva casilla con un muelle lento: nunca reaparece de golpe en otro sitio.
+ */
+@Composable
+private fun rememberOrbitas(
+    nodes: List<AppNode>,
+    firstR: Float,
+    ringGap: Float,
+    spacing: Float,
+): Orbitas {
+    val anguloPrevio = remember { HashMap<String, Float>() }
+    val firma = nodes.joinToString("|") { "${it.key}#${if (it.pinned) 1 else 0}#${it.penalty}" }
+    return remember(firma, firstR, ringGap, spacing) {
+        val slots = HashMap<String, Slot>(nodes.size)
+        var rings = 0
+        computePlacements(nodes.map { it.key }, firstR, ringGap, spacing).forEach { (clave, slot) ->
+            // El ángulo se "desenrolla" respecto al anterior para que la animación
+            // tome el camino corto y no dé una vuelta entera de más.
+            val previo = anguloPrevio[clave]
+            val angulo = if (previo == null) slot.angle else desenrollar(previo, slot.angle)
+            anguloPrevio[clave] = angulo
+            slots[clave] = slot.copy(angle = angulo)
+            rings = max(rings, slot.ring + 1)
+        }
+        anguloPrevio.keys.retainAll(slots.keys)
+        Orbitas(slots, rings)
+    }
+}
+
+private fun desenrollar(previo: Float, destino: Float): Float {
+    var a = destino
+    while (a - previo > TAU_F / 2f) a -= TAU_F
+    while (previo - a > TAU_F / 2f) a += TAU_F
+    return a
+}
+
+/** Color continuo a lo largo de la paleta del núcleo (t recorre 0..paleta.size). */
+private fun colorDelCiclo(t: Float): Color {
+    val n = NucleoPaleta.size
+    val base = floor(t).toInt()
+    val f = t - base
+    return lerp(NucleoPaleta[((base % n) + n) % n], NucleoPaleta[((base + 1) % n + n) % n], f)
+}
+
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
 private fun NodeItem(
@@ -183,25 +290,39 @@ private fun NodeItem(
     }
 }
 
+/**
+ * El corazón del átomo: un núcleo blanco muy brillante envuelto en el color del ciclo.
+ * El halo amplio lo pinta el lienzo de fondo; aquí solo va el punto vivo y su toque.
+ */
 @Composable
-private fun CenterHub(modifier: Modifier, onTap: () -> Unit) {
-    val transition = rememberInfiniteTransition(label = "hub")
-    val pulse by transition.animateFloat(
-        initialValue = 0.88f,
-        targetValue = 1.08f,
-        animationSpec = infiniteRepeatable(tween(2600, easing = LinearEasing), RepeatMode.Reverse),
-        label = "pulse",
-    )
+private fun CenterHub(
+    modifier: Modifier,
+    glow: () -> Color,
+    pulse: State<Float>,
+    onTap: () -> Unit,
+) {
     Column(
         modifier = modifier
-            .size(76.dp)
-            .scale(pulse)
+            .size(96.dp)
             .clip(CircleShape)
-            .background(
-                Brush.radialGradient(
-                    listOf(NeonVerde.copy(alpha = 0.22f), androidx.compose.ui.graphics.Color.Transparent)
+            .drawBehind {
+                val c = glow()
+                val r = size.minDimension / 2f * pulse.value
+                drawCircle(
+                    brush = Brush.radialGradient(
+                        0.00f to Color.White.copy(alpha = 0.42f),
+                        0.10f to c.copy(alpha = 0.80f),
+                        0.35f to c.copy(alpha = 0.34f),
+                        0.68f to c.copy(alpha = 0.10f),
+                        1.00f to Color.Transparent,
+                        center = center,
+                        radius = r,
+                    ),
+                    radius = r,
+                    center = center,
+                    blendMode = BlendMode.Plus,
                 )
-            )
+            }
             .clickable(
                 interactionSource = remember { MutableInteractionSource() },
                 indication = null,
@@ -211,37 +332,37 @@ private fun CenterHub(modifier: Modifier, onTap: () -> Unit) {
         verticalArrangement = Arrangement.Center,
     ) {
         Text(
-            text = "✦",
-            color = NeonVerde,
-            fontSize = 26.sp,
+            text = "\u2726",
+            color = Color.White.copy(alpha = 0.92f),
+            fontSize = 24.sp,
             fontWeight = FontWeight.Light,
         )
     }
 }
 
 /**
- * Reparte los nodos (ya ordenados) en anillos. Cada anillo crece hacia afuera y su
+ * Reparte las claves (ya ordenadas) en anillos. Cada anillo crece hacia afuera y su
  * capacidad depende de su circunferencia, así nada se amontona.
  */
 private fun computePlacements(
-    nodes: List<AppNode>,
+    keys: List<String>,
     firstR: Float,
     ringGap: Float,
     spacing: Float,
-): List<Placement> {
-    if (nodes.isEmpty()) return emptyList()
-    val result = ArrayList<Placement>(nodes.size)
+): List<Pair<String, Slot>> {
+    if (keys.isEmpty()) return emptyList()
+    val result = ArrayList<Pair<String, Slot>>(keys.size)
     var index = 0
     var ring = 0
-    while (index < nodes.size) {
+    while (index < keys.size) {
         val radius = firstR + ring * ringGap
         val circumference = 2.0 * PI * radius
         val capacity = max(4, floor(circumference / spacing).toInt())
-        val count = min(capacity, nodes.size - index)
-        val base = ring * 0.5
+        val count = min(capacity, keys.size - index)
+        val base = ring * 0.5f
         for (j in 0 until count) {
-            val angle = base + 2.0 * PI * j / count
-            result.add(Placement(nodes[index + j], ring, angle, radius))
+            val angle = base + TAU_F * j / count
+            result.add(keys[index + j] to Slot(ring, angle, radius))
         }
         index += count
         ring++
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
index a371fc7..8aa1549 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
@@ -17,6 +17,7 @@ import com.rizoma.orbita.data.Weather
 import com.rizoma.orbita.model.AppNode
 import com.rizoma.orbita.model.Buckets
 import com.rizoma.orbita.model.ExpelledNode
+import com.rizoma.orbita.model.SearchSort
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -43,6 +44,7 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
             node.copy(
                 score = (u.counts[node.key] ?: 0).toFloat(),
                 pinned = node.key in u.pinned,
+                penalty = u.penalties[node.key] ?: 0,
                 hidden = node.key in u.hidden,
                 hiddenAt = u.hiddenAt[node.key] ?: 0L,
             )
@@ -81,6 +83,17 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
         return Buckets(atom, nuevas, expulsadas, desterradas.sortedBy { it.label.lowercase() })
     }
 
+    // --- Búsqueda: criterio de orden (se recuerda entre sesiones). ---
+
+    /** Cómo se ordena la lista de la búsqueda. Por defecto, por frecuencia de uso. */
+    val searchSort: StateFlow<SearchSort> = usage.sortMode()
+        .map { SearchSort.from(it) }
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchSort.FRECUENCIA)
+
+    fun setSearchSort(sort: SearchSort) {
+        viewModelScope.launch { usage.setSortMode(sort.name) }
+    }
+
     // --- Cielo del fondo: hora del día + clima local (perezoso). ---
 
     private val _weather = MutableStateFlow<Weather?>(null)
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt b/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt
index fc08fe6..d07f918 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt
@@ -280,7 +280,7 @@ private fun BoxWithConstraintsScope.ZoneEmpty(text: String, align: Alignment) {
 
 /** Cinco puntos: el más cercano a la página actual brilla. Lee [progress] al dibujar. */
 @Composable
-fun PageDots(progress: () -> Float, modifier: Modifier) {
+fun PageDots(progress: () -> Float, modifier: Modifier = Modifier) {
     Canvas(modifier.fillMaxWidth().height(26.dp)) {
         val p = progress()
         val n = 5
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/theme/Theme.kt b/launcher/src/main/java/com/rizoma/orbita/ui/theme/Theme.kt
index 9dbe742..35898c5 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/theme/Theme.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/theme/Theme.kt
@@ -12,6 +12,15 @@ val NeonVerde = Color(0xFF7CFF4F)
 val NeonRosa = Color(0xFFFF2E88)
 val NeonCian = Color(0xFF00E5FF)
 val Tenue = Color(0xB3FFFFFF)
+val NeonVioleta = Color(0xFF9A6BFF)
+val NeonAmbar = Color(0xFFFFC15E)
+
+/**
+ * La deriva del núcleo: el centro del átomo recorre esta paleta en bucle, tan
+ * despacio (una vuelta completa cada 15 min) que el cambio no se percibe —
+ * solo se nota que el color de hace un rato ya no es el de ahora.
+ */
+val NucleoPaleta = listOf(NeonVerde, NeonCian, NeonVioleta, NeonRosa, NeonAmbar)
 
 private val EsquemaOrbita = darkColorScheme(
     primary = NeonVerde,
diff --git a/launcher/src/main/res/values/strings.xml b/launcher/src/main/res/values/strings.xml
index eda3875..3edbcc0 100644
--- a/launcher/src/main/res/values/strings.xml
+++ b/launcher/src/main/res/values/strings.xml
@@ -10,4 +10,8 @@
     <string name="menu_info">info</string>
     <string name="menu_uninstall">desinstalar</string>
     <string name="tag_hidden">fuera del átomo</string>
+    <string name="sort_frequency">frecuencia</string>
+    <string name="sort_name">nombre</string>
+    <string name="sort_recent">recientes</string>
+    <string name="brand">gioser.net</string>
 </resources>