gioser

Rizoma: nuevo módulo :notas — notas en grafo (zettelkasten rizomático)

App insignia del set: las notas son nodos conectados por enlaces [[wikilink]],
navegables como grafo en vez de lista jerárquica.

- Persistencia sin deps: un notas.json en filesDir (org.json), escritura atómica
  (.tmp + rename) con debounce 350ms; siembra de bienvenida al primer arranque.
- Enlaces [[titulo]] resueltos por título normalizado; aristas dirigidas,
  backlinks (retrocesos) y enlaces "fantasma" que crean la nota al tocarlos.
- Vista grafo: lienzo paneable/zoomable (detectTransformGestures, escala 0.4–3),
  nodos arrastrables con burbuja+halo neón, tamaño por grado; FAB para crear.
- Editor: título+cuerpo (BasicTextField), chips de enlaces en vivo y lista de
  retrocesos; autosalvado.
- Navegación por estado (Grafo|Editor), sin librería. Estética rizoma, offline.

Generado con un subagente bajo spec estricta; escrito NO compilado aquí.

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

a116b96ab8bba237bc543db8d7cf38ec55da73c9 · por sergio · (hace 4 meses) · padre cb2d1062c · ver el árbol en este commit

Delta semántico

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

16 archivos · +1126 −0

diff --git a/notas/build.gradle.kts b/notas/build.gradle.kts
new file mode 100644
index 0000000..a82a024
--- /dev/null
+++ b/notas/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.notas"
+    compileSdk = 36
+
+    defaultConfig {
+        applicationId = "com.rizoma.notas"
+        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/notas/src/main/AndroidManifest.xml b/notas/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..76b809a
--- /dev/null
+++ b/notas/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <!-- App offline: sin permisos. Las notas viven en el almacenamiento interno. -->
+
+    <application
+        android:label="@string/app_name"
+        android:icon="@mipmap/ic_launcher"
+        android:theme="@style/Theme.Rizoma"
+        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/notas/src/main/java/com/rizoma/notas/MainActivity.kt b/notas/src/main/java/com/rizoma/notas/MainActivity.kt
new file mode 100644
index 0000000..3c96986
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/MainActivity.kt
@@ -0,0 +1,20 @@
+package com.rizoma.notas
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.view.WindowCompat
+import com.rizoma.notas.ui.RizomaApp
+import com.rizoma.notas.ui.theme.RizomaTheme
+
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+        setContent {
+            RizomaTheme {
+                RizomaApp()
+            }
+        }
+    }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/NotasViewModel.kt b/notas/src/main/java/com/rizoma/notas/NotasViewModel.kt
new file mode 100644
index 0000000..a98f155
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/NotasViewModel.kt
@@ -0,0 +1,96 @@
+package com.rizoma.notas
+
+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.notas.data.NotasRepository
+import com.rizoma.notas.model.Grafo
+import com.rizoma.notas.model.Nota
+import com.rizoma.notas.model.claveTitulo
+import kotlinx.coroutines.launch
+
+/** Pantalla actual. Sin librería de navegación: un `when` sobre este estado. */
+sealed interface Pantalla {
+    data object Grafo : Pantalla
+    data class Editor(val notaId: String) : Pantalla
+}
+
+/**
+ * Estado de la app de notas. Mantiene la lista (recolectada del repositorio) como
+ * estado Compose y deriva el [Grafo] (aristas/grado/backlinks) bajo demanda.
+ */
+class NotasViewModel(app: Application) : AndroidViewModel(app) {
+
+    private val repo = NotasRepository(app, viewModelScope)
+
+    var notas by mutableStateOf<List<Nota>>(emptyList()); private set
+    var pantalla by mutableStateOf<Pantalla>(Pantalla.Grafo); private set
+
+    init {
+        viewModelScope.launch {
+            repo.notas.collect { notas = it }
+        }
+    }
+
+    /** Grafo derivado del estado actual; barato de reconstruir, se recalcula por recomposición. */
+    fun grafo(): Grafo = Grafo(notas)
+
+    fun nota(id: String): Nota? = notas.firstOrNull { it.id == id }
+
+    // ---- Navegación ----------------------------------------------------
+
+    fun abrir(id: String) { pantalla = Pantalla.Editor(id) }
+    fun volverAlGrafo() { pantalla = Pantalla.Grafo }
+
+    // ---- Mutaciones ----------------------------------------------------
+
+    /**
+     * Crea una nota nueva en (x,y) del mundo y abre su editor. El título es único
+     * por defecto para no colisionar con resoluciones de enlace.
+     */
+    fun crearEnLienzo(x: Float, y: Float) {
+        val titulo = tituloLibre("nota")
+        val id = repo.crear(titulo, "", x, y)
+        pantalla = Pantalla.Editor(id)
+    }
+
+    fun setTitulo(id: String, titulo: String) = repo.setTitulo(id, titulo)
+    fun setCuerpo(id: String, cuerpo: String) = repo.setCuerpo(id, cuerpo)
+    fun mover(id: String, x: Float, y: Float) = repo.mover(id, x, y)
+
+    fun eliminar(id: String) {
+        repo.eliminar(id)
+        if (pantalla.let { it is Pantalla.Editor && it.notaId == id }) {
+            pantalla = Pantalla.Grafo
+        }
+    }
+
+    /**
+     * Sigue un enlace `[[titulo]]` desde el editor: si la nota destino existe,
+     * navega a ella; si es fantasma, la crea (cerca del origen) y la abre.
+     */
+    fun seguirEnlace(titulo: String, origenId: String?) {
+        val destino = notas.firstOrNull { claveTitulo(it.titulo) == claveTitulo(titulo) }
+        if (destino != null) {
+            pantalla = Pantalla.Editor(destino.id)
+            return
+        }
+        val origen = origenId?.let { id -> notas.firstOrNull { it.id == id } }
+        val x = (origen?.x ?: 0f) + 180f
+        val y = (origen?.y ?: 0f) + 60f
+        val id = repo.crear(titulo.trim(), "", x, y)
+        pantalla = Pantalla.Editor(id)
+    }
+
+    /** Genera un título no usado del estilo "base", "base 2", "base 3"... */
+    private fun tituloLibre(base: String): String {
+        val usados = notas.mapTo(HashSet()) { claveTitulo(it.titulo) }
+        if (claveTitulo(base) !in usados) return base
+        var i = 2
+        while (claveTitulo("$base $i") in usados) i++
+        return "$base $i"
+    }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/data/NotasRepository.kt b/notas/src/main/java/com/rizoma/notas/data/NotasRepository.kt
new file mode 100644
index 0000000..a809af9
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/data/NotasRepository.kt
@@ -0,0 +1,173 @@
+package com.rizoma.notas.data
+
+import android.content.Context
+import com.rizoma.notas.model.Nota
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.launch
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.util.UUID
+
+/**
+ * Fuente de verdad de las notas. Carga/guarda una única lista en un fichero JSON
+ * dentro de `filesDir` (sin Room, sin deps nuevas — usa `org.json`).
+ *
+ * El estado vive en [notas] (StateFlow). Cada mutación reemplaza la lista; un
+ * recolector con `debounce` vuelca a disco en el dispatcher de IO, de modo que
+ * arrastres y tecleos rápidos no martillean el disco.
+ */
+@OptIn(FlowPreview::class)
+class NotasRepository(context: Context, scope: CoroutineScope) {
+
+    private val fichero = File(context.applicationContext.filesDir, FICHERO)
+
+    private val _notas = MutableStateFlow(cargar())
+    val notas: StateFlow<List<Nota>> = _notas.asStateFlow()
+
+    init {
+        // Persistencia reactiva: tras el primer valor (ya en disco), se guarda
+        // cada cambio con un pequeño retardo para agrupar ráfagas.
+        scope.launch(Dispatchers.IO) {
+            _notas
+                .drop(1)            // el estado inicial ya está en disco (o es semilla recién guardada)
+                .debounce(350)
+                .collect { guardar(it) }
+        }
+    }
+
+    // ---- Mutaciones -----------------------------------------------------
+
+    /** Crea una nota nueva en (x,y) y devuelve su id. */
+    fun crear(titulo: String, cuerpo: String, x: Float, y: Float): String {
+        val ahora = System.currentTimeMillis()
+        val nota = Nota(
+            id = UUID.randomUUID().toString(),
+            titulo = titulo,
+            cuerpo = cuerpo,
+            x = x,
+            y = y,
+            creada = ahora,
+            modificada = ahora,
+        )
+        _notas.value = _notas.value + nota
+        return nota.id
+    }
+
+    /** Aplica un cambio a la nota [id]; marca `modificada`. No-op si no existe. */
+    fun actualizar(id: String, transform: (Nota) -> Nota) {
+        var cambio = false
+        val nuevas = _notas.value.map {
+            if (it.id == id) { cambio = true; transform(it).copy(modificada = System.currentTimeMillis()) }
+            else it
+        }
+        if (cambio) _notas.value = nuevas
+    }
+
+    fun setTitulo(id: String, titulo: String) = actualizar(id) { it.copy(titulo = titulo) }
+    fun setCuerpo(id: String, cuerpo: String) = actualizar(id) { it.copy(cuerpo = cuerpo) }
+
+    /** Reposiciona el nodo en el lienzo (durante/tras un arrastre). */
+    fun mover(id: String, x: Float, y: Float) = actualizar(id) { it.copy(x = x, y = y) }
+
+    fun eliminar(id: String) {
+        _notas.value = _notas.value.filterNot { it.id == id }
+    }
+
+    fun buscar(id: String?): Nota? = id?.let { i -> _notas.value.firstOrNull { it.id == i } }
+
+    // ---- Carga / guardado ----------------------------------------------
+
+    private fun cargar(): List<Nota> {
+        val datos = runCatching {
+            if (!fichero.exists()) return@runCatching emptyList<Nota>()
+            val raw = fichero.readText()
+            if (raw.isBlank()) return@runCatching emptyList<Nota>()
+            val arr = JSONArray(raw)
+            (0 until arr.length()).map { i -> notaDeJson(arr.getJSONObject(i)) }
+        }.getOrElse { emptyList() }
+
+        if (datos.isNotEmpty()) return datos
+
+        // Primer arranque (o fichero corrupto): notas semilla enlazadas entre sí.
+        val semilla = semillas()
+        guardar(semilla)   // persiste ya, para que el `drop(1)` no la pise
+        return semilla
+    }
+
+    private fun guardar(lista: List<Nota>) {
+        runCatching {
+            val arr = JSONArray()
+            for (n in lista) arr.put(notaAJson(n))
+            // Escritura atómica: a temporal y renombre, para no dejar JSON a medias.
+            val tmp = File(fichero.parentFile, "$FICHERO.tmp")
+            tmp.writeText(arr.toString())
+            if (!tmp.renameTo(fichero)) {
+                fichero.writeText(arr.toString())
+                tmp.delete()
+            }
+        }
+    }
+
+    private fun notaAJson(n: Nota) = JSONObject().apply {
+        put("id", n.id)
+        put("titulo", n.titulo)
+        put("cuerpo", n.cuerpo)
+        put("x", n.x.toDouble())
+        put("y", n.y.toDouble())
+        put("creada", n.creada)
+        put("modificada", n.modificada)
+    }
+
+    private fun notaDeJson(o: JSONObject): Nota {
+        val ahora = System.currentTimeMillis()
+        return Nota(
+            id = o.optString("id").ifBlank { UUID.randomUUID().toString() },
+            titulo = o.optString("titulo"),
+            cuerpo = o.optString("cuerpo"),
+            x = o.optDouble("x", 0.0).toFloat(),
+            y = o.optDouble("y", 0.0).toFloat(),
+            creada = o.optLong("creada", ahora),
+            modificada = o.optLong("modificada", ahora),
+        )
+    }
+
+    private fun semillas(): List<Nota> {
+        val ahora = System.currentTimeMillis()
+        fun nota(titulo: String, cuerpo: String, x: Float, y: Float) = Nota(
+            id = UUID.randomUUID().toString(),
+            titulo = titulo, cuerpo = cuerpo, x = x, y = y,
+            creada = ahora, modificada = ahora,
+        )
+        return listOf(
+            nota(
+                "rizoma",
+                "esto es un rizoma: no hay carpetas, solo nodos.\n\n" +
+                    "escribe enlaces con dobles corchetes y se vuelven aristas: " +
+                    "[[cómo enlazar]].\n\n" +
+                    "toca un nodo para abrirlo, arrástralo para moverlo.",
+                -160f, -40f,
+            ),
+            nota(
+                "cómo enlazar",
+                "rodea cualquier título con [[corchetes dobles]] y aparecerá una arista " +
+                    "hacia esa nota.\n\n" +
+                    "si la nota no existe todavía, el enlace sale como [[idea fantasma]]: " +
+                    "tócalo y se crea.\n\n" +
+                    "vuelve al [[rizoma]].",
+                160f, 60f,
+            ),
+        )
+    }
+
+    private companion object {
+        const val FICHERO = "notas.json"
+    }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/model/Nota.kt b/notas/src/main/java/com/rizoma/notas/model/Nota.kt
new file mode 100644
index 0000000..a01b0a8
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/model/Nota.kt
@@ -0,0 +1,103 @@
+package com.rizoma.notas.model
+
+/**
+ * Un nodo del rizoma. No hay jerarquía: las notas se relacionan por enlaces
+ * `[[wikilink]]` embebidos en el cuerpo, no por carpetas.
+ *
+ * - [id]        identidad estable (UUID); sobrevive a renombrados de título.
+ * - [titulo]    cómo la referencian los enlaces (resolución por título, no por id).
+ * - [cuerpo]    texto markdown plano; contiene los `[[...]]`.
+ * - [x], [y]    posición persistida en el lienzo del grafo (coords de "mundo").
+ * - [creada]    / [modificada] epoch millis.
+ */
+data class Nota(
+    val id: String,
+    val titulo: String,
+    val cuerpo: String,
+    val x: Float,
+    val y: Float,
+    val creada: Long,
+    val modificada: Long,
+) {
+    /**
+     * Acento neón estable: derivado del id, así un nodo conserva su color entre
+     * sesiones aunque cambie su grado o el orden de la lista. 4 colores en la rueda.
+     */
+    val acentoIndice: Int get() = (Math.floorMod(id.hashCode(), 4))
+}
+
+/** Normaliza un título para comparar/resolver enlaces (minúsculas + sin espacios extra). */
+fun claveTitulo(titulo: String): String = titulo.trim().lowercase()
+
+private val REGEX_ENLACE = Regex("""\[\[([^\]\[]+)\]\]""")
+
+/**
+ * Extrae los títulos enlazados desde un cuerpo. Devuelve los textos crudos (sin
+ * normalizar) en orden de aparición y sin duplicados por clave normalizada.
+ */
+fun enlacesDe(cuerpo: String): List<String> {
+    val vistos = LinkedHashMap<String, String>()
+    for (m in REGEX_ENLACE.findAll(cuerpo)) {
+        val crudo = m.groupValues[1].trim()
+        if (crudo.isEmpty()) continue
+        val clave = claveTitulo(crudo)
+        if (clave !in vistos) vistos[clave] = crudo
+    }
+    return vistos.values.toList()
+}
+
+/**
+ * Una arista dirigida del grafo: la nota [desde] enlaza a la nota [hacia] (ambas
+ * existentes). Se dibuja sin flecha; los backlinks son su inversa.
+ */
+data class Arista(val desde: String, val hacia: String)
+
+/** Vista derivada del conjunto de notas: índice por título + aristas resueltas. */
+class Grafo(val notas: List<Nota>) {
+
+    // título normalizado -> nota (la primera gana si hubiera colisión de títulos).
+    // Primer título gana ante colisiones; LinkedHashMap conserva orden de inserción.
+    private val porTitulo: Map<String, Nota> = LinkedHashMap<String, Nota>().apply {
+        for (n in notas) putIfAbsent(claveTitulo(n.titulo), n)
+    }
+
+    /** Resuelve un título (crudo o normalizado) a una nota existente, o null si es fantasma. */
+    fun resolver(titulo: String): Nota? = porTitulo[claveTitulo(titulo)]
+
+    /** Aristas dirigidas entre notas existentes (enlaces fantasma se omiten aquí). */
+    val aristas: List<Arista> by lazy {
+        val out = ArrayList<Arista>()
+        for (n in notas) {
+            for (t in enlacesDe(n.cuerpo)) {
+                val destino = porTitulo[claveTitulo(t)] ?: continue
+                if (destino.id != n.id) out.add(Arista(n.id, destino.id))
+            }
+        }
+        out
+    }
+
+    /** Grado (conexiones únicas, sin dirección) por id de nota; base del tamaño del nodo. */
+    val grado: Map<String, Int> by lazy {
+        val pares = HashSet<Long>()      // par no dirigido codificado
+        val cuenta = HashMap<String, Int>()
+        val indice = notas.withIndex().associate { it.value.id to it.index }
+        for (a in aristas) {
+            val i = indice[a.desde] ?: continue
+            val j = indice[a.hacia] ?: continue
+            val lo = minOf(i, j).toLong(); val hi = maxOf(i, j).toLong()
+            if (pares.add(lo * 1_000_003L + hi)) {
+                cuenta[a.desde] = (cuenta[a.desde] ?: 0) + 1
+                cuenta[a.hacia] = (cuenta[a.hacia] ?: 0) + 1
+            }
+        }
+        cuenta
+    }
+
+    /** Notas que enlazan a [id] (aristas inversas) = retrocesos / backlinks. */
+    fun backlinks(id: String): List<Nota> {
+        val idx = notas.associateBy { it.id }
+        return aristas.filter { it.hacia == id }
+            .mapNotNull { idx[it.desde] }
+            .distinctBy { it.id }
+    }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/ui/EditorView.kt b/notas/src/main/java/com/rizoma/notas/ui/EditorView.kt
new file mode 100644
index 0000000..4994a80
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/ui/EditorView.kt
@@ -0,0 +1,238 @@
+package com.rizoma.notas.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.heightIn
+import androidx.compose.foundation.layout.padding
+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.text.BasicTextField
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.notas.NotasViewModel
+import com.rizoma.notas.model.Nota
+import com.rizoma.notas.model.claveTitulo
+import com.rizoma.notas.model.enlacesDe
+import com.rizoma.notas.ui.theme.MuyTenue
+import com.rizoma.notas.ui.theme.NeonCian
+import com.rizoma.notas.ui.theme.NeonRosa
+import com.rizoma.notas.ui.theme.NeonVerde
+import com.rizoma.notas.ui.theme.SuperficieAlta
+import com.rizoma.notas.ui.theme.Tenue
+import com.rizoma.notas.ui.theme.Vacio
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Editor de una nota. Título y cuerpo se editan con [BasicTextField]; cada cambio
+ * se empuja al ViewModel y el repositorio lo persiste con debounce (autosalvado).
+ *
+ * Bajo el cuerpo se muestran, en vivo:
+ *  - los `[[enlaces]]` detectados como chips (existentes navegan; fantasmas crean),
+ *  - los "retrocesos" (backlinks): notas que enlazan a esta.
+ */
+@Composable
+fun EditorView(vm: NotasViewModel, notaId: String) {
+    val nota: Nota? = vm.nota(notaId)
+    if (nota == null) {
+        // La nota fue eliminada bajo nuestros pies: vuelve al grafo.
+        Box(Modifier.fillMaxSize().background(Vacio))
+        return
+    }
+
+    // Estado de edición local, sembrado una vez por nota. Las pulsaciones rápidas
+    // actualizan aquí y se propagan al VM; no re-sembramos en cada recomposición.
+    var titulo by remember(notaId) { mutableStateOf(nota.titulo) }
+    var cuerpo by remember(notaId) { mutableStateOf(nota.cuerpo) }
+
+    val enlaces = remember(cuerpo) { enlacesDe(cuerpo) }
+    val existentes = remember(vm.notas) { vm.notas.mapTo(HashSet()) { claveTitulo(it.titulo) } }
+    val backlinks = vm.grafo().backlinks(notaId)
+
+    Column(
+        Modifier
+            .fillMaxSize()
+            .background(Vacio)
+            .windowInsetsPadding(WindowInsets.systemBars)
+            .padding(20.dp),
+    ) {
+        // Barra superior: volver + eliminar.
+        Row(
+            Modifier.fillMaxWidth().padding(bottom = 12.dp),
+            horizontalArrangement = Arrangement.SpaceBetween,
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Text(
+                "‹ grafo",
+                color = Tenue,
+                fontSize = 15.sp,
+                fontWeight = FontWeight.Medium,
+                modifier = Modifier
+                    .clip(RoundedCornerShape(50))
+                    .clickable { vm.volverAlGrafo() }
+                    .padding(horizontal = 12.dp, vertical = 8.dp),
+            )
+            Text(
+                "eliminar",
+                color = NeonRosa,
+                fontSize = 13.sp,
+                modifier = Modifier
+                    .clip(RoundedCornerShape(50))
+                    .clickable { vm.eliminar(notaId) }
+                    .padding(horizontal = 12.dp, vertical = 8.dp),
+            )
+        }
+
+        // Título.
+        BasicTextField(
+            value = titulo,
+            onValueChange = { titulo = it; vm.setTitulo(notaId, it) },
+            textStyle = TextStyle(color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Bold),
+            cursorBrush = SolidColor(NeonVerde),
+            singleLine = true,
+            modifier = Modifier.fillMaxWidth(),
+            decorationBox = { inner ->
+                if (titulo.isEmpty()) {
+                    Text("título", color = MuyTenue, fontSize = 26.sp, fontWeight = FontWeight.Bold)
+                }
+                inner()
+            },
+        )
+
+        Spacer(Modifier.padding(top = 6.dp))
+
+        // Cuerpo (markdown plano; admite [[enlaces]]). Ocupa el espacio central.
+        BasicTextField(
+            value = cuerpo,
+            onValueChange = { cuerpo = it; vm.setCuerpo(notaId, it) },
+            textStyle = TextStyle(color = Tenue, fontSize = 16.sp, lineHeight = 24.sp),
+            cursorBrush = SolidColor(NeonVerde),
+            modifier = Modifier
+                .fillMaxWidth()
+                .weight(1f)
+                .verticalScroll(rememberScrollState()),
+            decorationBox = { inner ->
+                if (cuerpo.isEmpty()) {
+                    Text(
+                        "escribe…  enlaza con [[otra nota]]",
+                        color = MuyTenue,
+                        fontSize = 16.sp,
+                    )
+                }
+                inner()
+            },
+        )
+
+        // Chips de enlaces detectados.
+        if (enlaces.isNotEmpty()) {
+            Etiqueta("enlaces")
+            Row(
+                Modifier
+                    .fillMaxWidth()
+                    .horizontalScroll(rememberScrollState())
+                    .padding(top = 6.dp, bottom = 4.dp),
+            ) {
+                for (e in enlaces) {
+                    val existe = claveTitulo(e) in existentes
+                    Chip(
+                        texto = if (existe) e else "$e  +",
+                        acento = if (existe) NeonCian else MuyTenue,
+                        fantasma = !existe,
+                        onClick = { vm.seguirEnlace(e, notaId) },
+                    )
+                    Spacer(Modifier.width(8.dp))
+                }
+            }
+        }
+
+        // Retrocesos (backlinks): quién enlaza aquí.
+        if (backlinks.isNotEmpty()) {
+            Etiqueta("retrocesos")
+            Column(
+                Modifier
+                    .fillMaxWidth()
+                    .heightIn(max = 140.dp)
+                    .verticalScroll(rememberScrollState())
+                    .padding(top = 6.dp),
+            ) {
+                for (b in backlinks) {
+                    Row(
+                        Modifier
+                            .fillMaxWidth()
+                            .clip(RoundedCornerShape(10.dp))
+                            .clickable { vm.abrir(b.id) }
+                            .padding(vertical = 8.dp, horizontal = 4.dp),
+                        verticalAlignment = Alignment.CenterVertically,
+                    ) {
+                        Text("←", color = NeonRosa, fontSize = 14.sp, modifier = Modifier.width(22.dp))
+                        Text(
+                            b.titulo.ifBlank { "sin título" },
+                            color = Tenue,
+                            fontSize = 15.sp,
+                            maxLines = 1,
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun Etiqueta(texto: String) {
+    Text(
+        texto.uppercase(),
+        color = MuyTenue,
+        fontSize = 10.sp,
+        letterSpacing = 3.sp,
+        modifier = Modifier.padding(top = 10.dp),
+    )
+}
+
+@Composable
+private fun Chip(texto: String, acento: Color, fantasma: Boolean, onClick: () -> Unit) {
+    Box(
+        Modifier
+            .clip(RoundedCornerShape(50))
+            .background(SuperficieAlta)
+            .border(
+                width = 1.dp,
+                color = if (fantasma) acento.copy(alpha = 0.5f) else acento,
+                shape = RoundedCornerShape(50),
+            )
+            .clickable { onClick() }
+            .padding(horizontal = 14.dp, vertical = 8.dp),
+    ) {
+        Text(
+            texto,
+            color = if (fantasma) MuyTenue else Color.White,
+            fontSize = 13.sp,
+            fontWeight = FontWeight.Medium,
+        )
+    }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/ui/GraphView.kt b/notas/src/main/java/com/rizoma/notas/ui/GraphView.kt
new file mode 100644
index 0000000..514678a
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/ui/GraphView.kt
@@ -0,0 +1,314 @@
+package com.rizoma.notas.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.notas.NotasViewModel
+import com.rizoma.notas.model.Nota
+import com.rizoma.notas.ui.theme.Acentos
+import com.rizoma.notas.ui.theme.MuyTenue
+import com.rizoma.notas.ui.theme.SuperficieAlta
+import com.rizoma.notas.ui.theme.Tenue
+import com.rizoma.notas.ui.theme.Vacio
+import kotlin.math.roundToInt
+
+private const val ESCALA_MIN = 0.4f
+private const val ESCALA_MAX = 3f
+
+/**
+ * Vista grafo: el lienzo rizomático. Se panea/zooma con [detectTransformGestures]
+ * sobre el contenedor y el "mundo" se transforma con [graphicsLayer]
+ * (translation + scale, origen arriba-izquierda).
+ *
+ * Detalle clave de coordenadas: las notas y nodos se posicionan en coords de
+ * "mundo"; como los nodos viven *dentro* del graphicsLayer, los desplazamientos
+ * de arrastre que reciben ya vienen en unidades de mundo (Compose invierte la
+ * transformación del layer al repartir los punteros), así que se suman directos.
+ */
+@Composable
+fun GraphView(vm: NotasViewModel) {
+    val grafo = vm.grafo()
+    val grados = grafo.grado
+
+    // Transformación del viewport.
+    var escala by remember { mutableStateOf(1f) }
+    var desplazamiento by remember { mutableStateOf(Offset.Zero) }
+    var viewport by remember { mutableStateOf(IntSize.Zero) }
+    var centrado by remember { mutableStateOf(false) }
+
+    // Nodo seleccionado por pulsación larga (muestra barra de acciones).
+    var seleccionado by remember { mutableStateOf<String?>(null) }
+
+    Box(
+        Modifier
+            .fillMaxSize()
+            .background(Vacio)
+            .clipToBounds()
+            .onSizeChanged {
+                viewport = it
+                if (!centrado && it.width > 0 && it.height > 0) {
+                    // Origen de mundo (0,0) al centro del viewport en el primer layout.
+                    desplazamiento = Offset(it.width / 2f, it.height / 2f)
+                    centrado = true
+                }
+            }
+            .pointerInput(Unit) {
+                detectTransformGestures { centroide, pan, zoom, _ ->
+                    val nueva = (escala * zoom).coerceIn(ESCALA_MIN, ESCALA_MAX)
+                    // Zoom anclado al centroide: el punto de mundo bajo los dedos no se mueve.
+                    desplazamiento = centroide - (centroide - desplazamiento) * (nueva / escala) + pan
+                    escala = nueva
+                    seleccionado = null
+                }
+            },
+    ) {
+        // El "mundo": todo lo que se panea/zooma cuelga de aquí.
+        Box(
+            Modifier
+                .fillMaxSize()
+                .graphicsLayer {
+                    translationX = desplazamiento.x
+                    translationY = desplazamiento.y
+                    scaleX = escala
+                    scaleY = escala
+                    transformOrigin = TransformOrigin(0f, 0f)
+                },
+        ) {
+            // Aristas detrás (líneas tenues entre nodos enlazados existentes).
+            val porId = remember(vm.notas) { vm.notas.associateBy { it.id } }
+            Canvas(Modifier.fillMaxSize()) {
+                for (a in grafo.aristas) {
+                    val d = porId[a.desde] ?: continue
+                    val h = porId[a.hacia] ?: continue
+                    drawLine(
+                        color = Color.White.copy(alpha = 0.10f),
+                        start = Offset(d.x, d.y),
+                        end = Offset(h.x, h.y),
+                        strokeWidth = 1.5.dp.toPx(),
+                    )
+                }
+            }
+
+            // Nodos encima.
+            for (nota in vm.notas) {
+                NodoGrafo(
+                    nota = nota,
+                    grado = grados[nota.id] ?: 0,
+                    seleccionado = seleccionado == nota.id,
+                    onTap = { vm.abrir(nota.id) },
+                    onLongPress = { seleccionado = nota.id },
+                    // Lee la posición actual desde el VM (el closure del gesto no se
+                    // recompone): así el arrastre acumula sobre la última posición.
+                    onDrag = { dx, dy ->
+                        val cur = vm.nota(nota.id)
+                        if (cur != null) vm.mover(nota.id, cur.x + dx, cur.y + dy)
+                    },
+                )
+            }
+        }
+
+        // FAB "+": crea una nota en el centro del viewport actual (coords de mundo).
+        Surface(
+            color = SuperficieAlta,
+            shape = RoundedCornerShape(50),
+            modifier = Modifier
+                .align(Alignment.BottomEnd)
+                .windowInsetsPadding(WindowInsets.systemBars)
+                .padding(24.dp)
+                .size(60.dp)
+                .clip(RoundedCornerShape(50))
+                .clickable {
+                    val s = escala.coerceAtLeast(0.0001f)
+                    val cx = (viewport.width / 2f - desplazamiento.x) / s
+                    val cy = (viewport.height / 2f - desplazamiento.y) / s
+                    vm.crearEnLienzo(cx, cy)
+                },
+        ) {
+            Box(contentAlignment = Alignment.Center) {
+                Text("+", color = Tenue, fontSize = 30.sp, fontWeight = FontWeight.Light)
+            }
+        }
+
+        // Pista cuando el lienzo está vacío.
+        if (vm.notas.isEmpty()) {
+            Text(
+                "toca + para sembrar la primera nota",
+                color = MuyTenue,
+                fontSize = 14.sp,
+                modifier = Modifier.align(Alignment.Center),
+            )
+        }
+
+        // Barra de acciones por pulsación larga.
+        val sel = seleccionado
+        if (sel != null) {
+            BarraNodo(
+                titulo = vm.nota(sel)?.titulo.orEmpty(),
+                onEliminar = { vm.eliminar(sel); seleccionado = null },
+                onCancelar = { seleccionado = null },
+                modifier = Modifier
+                    .align(Alignment.BottomCenter)
+                    .windowInsetsPadding(WindowInsets.systemBars)
+                    .padding(20.dp),
+            )
+        }
+    }
+}
+
+/**
+ * Un nodo: burbuja con halo neón cuyo tamaño crece con el grado (nº de conexiones).
+ * Tap abre el editor; pulsación larga lo selecciona; arrastre lo reposiciona.
+ */
+@Composable
+private fun NodoGrafo(
+    nota: Nota,
+    grado: Int,
+    seleccionado: Boolean,
+    onTap: () -> Unit,
+    onLongPress: () -> Unit,
+    onDrag: (Float, Float) -> Unit,
+) {
+    val acento = Acentos[nota.acentoIndice % Acentos.size]
+    // Diámetro: base + aporte por grado, acotado. Sin tamaños negativos.
+    val diametroDp = (54 + grado.coerceIn(0, 8) * 9).dp
+    // La caja mide el doble para dejar sitio al halo; su centro cae sobre (x,y).
+    val cajaDp = diametroDp * 2
+
+    Box(
+        Modifier
+            // Centra la caja sobre (x,y): el offset coloca la esquina sup-izq,
+            // así que restamos medio lado (= diametroDp en px) a cada coord.
+            .offset {
+                val medio = diametroDp.toPx()
+                IntOffset((nota.x - medio).roundToInt(), (nota.y - medio).roundToInt())
+            }
+            .size(cajaDp)
+            // Gestos: arrastre (reposiciona) y tap/pulsación-larga.
+            .pointerInput(nota.id) {
+                detectDragGestures { change, drag ->
+                    change.consume()
+                    onDrag(drag.x, drag.y)
+                }
+            }
+            .pointerInput(nota.id) {
+                detectTapGestures(
+                    onTap = { onTap() },
+                    onLongPress = { onLongPress() },
+                )
+            },
+        contentAlignment = Alignment.Center,
+    ) {
+        Canvas(Modifier.size(diametroDp)) {
+            val r = size.minDimension / 2f
+            val centro = Offset(size.width / 2f, size.height / 2f)
+            // Halo neón (anillos concéntricos translúcidos).
+            drawCircle(color = acento.copy(alpha = 0.16f), radius = r, center = centro)
+            drawCircle(color = acento.copy(alpha = 0.10f), radius = r * 1.25f, center = centro)
+            // Cuerpo de la burbuja.
+            drawCircle(color = SuperficieAlta, radius = r * 0.82f, center = centro)
+            // Borde neón (más intenso si está seleccionado).
+            drawCircle(
+                color = if (seleccionado) acento else acento.copy(alpha = 0.85f),
+                radius = r * 0.82f,
+                center = centro,
+                style = androidx.compose.ui.graphics.drawscope.Stroke(
+                    width = if (seleccionado) 3.5.dp.toPx() else 2.dp.toPx(),
+                ),
+            )
+        }
+        Text(
+            text = nota.titulo.ifBlank { "·" },
+            color = Color.White,
+            fontSize = 12.sp,
+            fontWeight = FontWeight.Medium,
+            maxLines = 2,
+            textAlign = TextAlign.Center,
+            modifier = Modifier.width(diametroDp * 0.85f),
+        )
+    }
+}
+
+@Composable
+private fun BarraNodo(
+    titulo: String,
+    onEliminar: () -> Unit,
+    onCancelar: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    Surface(color = SuperficieAlta, shape = RoundedCornerShape(16.dp), modifier = modifier.fillMaxWidth()) {
+        Row(
+            Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 14.dp),
+            horizontalArrangement = Arrangement.SpaceBetween,
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Text(
+                titulo.ifBlank { "sin título" },
+                color = Tenue,
+                fontSize = 14.sp,
+                fontWeight = FontWeight.Medium,
+                maxLines = 1,
+                modifier = Modifier.padding(end = 12.dp),
+            )
+            Row(verticalAlignment = Alignment.CenterVertically) {
+                Text(
+                    "cancelar",
+                    color = MuyTenue,
+                    fontSize = 14.sp,
+                    modifier = Modifier
+                        .clip(RoundedCornerShape(50))
+                        .clickable { onCancelar() }
+                        .padding(horizontal = 12.dp, vertical = 8.dp),
+                )
+                Text(
+                    "eliminar",
+                    color = com.rizoma.notas.ui.theme.NeonRosa,
+                    fontSize = 14.sp,
+                    fontWeight = FontWeight.Bold,
+                    modifier = Modifier
+                        .clip(RoundedCornerShape(50))
+                        .clickable { onEliminar() }
+                        .padding(horizontal = 12.dp, vertical = 8.dp),
+                )
+            }
+        }
+    }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/ui/RizomaApp.kt b/notas/src/main/java/com/rizoma/notas/ui/RizomaApp.kt
new file mode 100644
index 0000000..0d8cecd
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/ui/RizomaApp.kt
@@ -0,0 +1,24 @@
+package com.rizoma.notas.ui
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.runtime.Composable
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.rizoma.notas.NotasViewModel
+import com.rizoma.notas.Pantalla
+
+/**
+ * Raíz de navegación. Dos vistas conmutables (grafo / editor) seleccionadas por
+ * un `when` sobre [Pantalla], al estilo de :consola. El editor captura el botón
+ * atrás para volver al grafo en vez de cerrar la app.
+ */
+@Composable
+fun RizomaApp(vm: NotasViewModel = viewModel()) {
+    when (val p = vm.pantalla) {
+        Pantalla.Grafo -> GraphView(vm)
+
+        is Pantalla.Editor -> {
+            BackHandler { vm.volverAlGrafo() }
+            EditorView(vm, notaId = p.notaId)
+        }
+    }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/ui/theme/Theme.kt b/notas/src/main/java/com/rizoma/notas/ui/theme/Theme.kt
new file mode 100644
index 0000000..ae60e62
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/ui/theme/Theme.kt
@@ -0,0 +1,40 @@
+package com.rizoma.notas.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 NeonViola = Color(0xFFB45CFF)
+val Tenue = Color(0xB3FFFFFF)
+val MuyTenue = Color(0x66FFFFFF)
+
+// Rueda de acentos neón; se reparten por nodo de forma estable (ver Nota.acento).
+val Acentos = listOf(NeonVerde, NeonRosa, NeonCian, NeonViola)
+
+private val EsquemaNotas = darkColorScheme(
+    primary = NeonVerde,
+    onPrimary = Vacio,
+    secondary = NeonRosa,
+    onSecondary = Vacio,
+    tertiary = NeonCian,
+    onTertiary = Vacio,
+    background = Vacio,
+    onBackground = Color.White,
+    surface = Superficie,
+    onSurface = Color.White,
+    surfaceVariant = SuperficieAlta,
+    onSurfaceVariant = Tenue,
+)
+
+@Composable
+fun RizomaTheme(content: @Composable () -> Unit) {
+    MaterialTheme(colorScheme = EsquemaNotas, content = content)
+}
diff --git a/notas/src/main/res/drawable/ic_launcher_fg.xml b/notas/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..7aaa343
--- /dev/null
+++ b/notas/src/main/res/drawable/ic_launcher_fg.xml
@@ -0,0 +1,22 @@
+<?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">
+    <!-- un grafo en miniatura: cuatro nodos neón unidos por aristas tenues -->
+    <!-- aristas primero, para que los nodos queden encima -->
+    <path android:strokeColor="#33FFFFFF" android:strokeWidth="2"
+        android:pathData="M38 40 L70 34" />
+    <path android:strokeColor="#33FFFFFF" android:strokeWidth="2"
+        android:pathData="M38 40 L46 70" />
+    <path android:strokeColor="#33FFFFFF" android:strokeWidth="2"
+        android:pathData="M70 34 L46 70" />
+    <path android:strokeColor="#33FFFFFF" android:strokeWidth="2"
+        android:pathData="M46 70 L74 72" />
+    <!-- nodos -->
+    <path android:fillColor="#7CFF4F" android:pathData="M38 40 m-9 0 a9 9 0 1 0 18 0 a9 9 0 1 0 -18 0 z" />
+    <path android:fillColor="#00E5FF" android:pathData="M70 34 m-7 0 a7 7 0 1 0 14 0 a7 7 0 1 0 -14 0 z" />
+    <path android:fillColor="#FF2E88" android:pathData="M46 70 m-10 0 a10 10 0 1 0 20 0 a10 10 0 1 0 -20 0 z" />
+    <path android:fillColor="#B45CFF" android:pathData="M74 72 m-6 0 a6 6 0 1 0 12 0 a6 6 0 1 0 -12 0 z" />
+</vector>
diff --git a/notas/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/notas/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..088fcce
--- /dev/null
+++ b/notas/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/notas/src/main/res/values/colors.xml b/notas/src/main/res/values/colors.xml
new file mode 100644
index 0000000..917256d
--- /dev/null
+++ b/notas/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/notas/src/main/res/values/strings.xml b/notas/src/main/res/values/strings.xml
new file mode 100644
index 0000000..dcfeaa2
--- /dev/null
+++ b/notas/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">rizoma</string>
+</resources>
diff --git a/notas/src/main/res/values/themes.xml b/notas/src/main/res/values/themes.xml
new file mode 100644
index 0000000..43af6c4
--- /dev/null
+++ b/notas/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="Theme.Rizoma" 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 17ccf4b..190a27e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -25,3 +25,4 @@ include(":keyboard")
 include(":consola")
 include(":matiz")
 include(":nimbo")
+include(":notas")