feat(tahuantinsuyu): "Cartas libres" como sección + guardar a contacto (fase A)

Estructura de cartas no-persistidas con CRUD básico en la UI.

Modelo:
- `FreeChartId(String)` con sentinela `sky_now()` reservado para la
  carta del cielo. Otros ids se generan al vuelo como `free-N`.
- `TreeSelection::FreeChart(FreeChartId)` y `FreeChartsRoot`
  reemplazan al variante puntual `PresentSky` (que era un caso
  especial paralelo).

Tree:
- Sección **"🜨 Cartas libres"** branch fijo al FONDO del tree
  (al contrario de "◇ General" que va arriba). Contiene "Cielo
  ahora" como primera leaf + cualquier carta libre creada.
  Expandida por default.
- Menu contextual:
  * sobre la sección: "Nueva carta libre" → `NewFreeChartRequested`
  * sobre una carta libre: "Guardar como…" + "Borrar" (`sky-now`
    no admite borrar)
- Setter `set_free_charts(Vec<FreeChartEntry>)` actualizado por
  el shell tras cada mutación.

Shell:
- Nuevo state: `free_charts: HashMap<FreeChartId, Chart>` +
  `next_free_id: u32`.
- `ensure_sky_now` inserta/refresca "Cielo ahora" contra el
  reloj actual. Al boot se llama y la carta queda seleccionada.
- `push_free_charts_to_tree` publica la lista al tree
  (sky-now primero, después los `free-N` ordenados).
- Handlers de los 3 nuevos eventos:
  * `NewFreeChartRequested` → crea entry, selecciona
  * `SaveFreeChartRequested(id)` → `save_free_chart_quick`
    (MVP: crea contacto nuevo con el label de la carta + carta
    bajo él; la fase B reemplaza por modal con dropdown)
  * `DeleteFreeChartRequested(id)` → quita de free_charts;
    si era la activa, vuelve al Cielo

10 tests verdes (sin cambios — la lógica nueva afecta paths que
no están cubiertos en los smoke tests actuales).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-18 21:11:36 +00:00
parent 72758e75ce
commit 72da2934e8
3 changed files with 336 additions and 51 deletions
@@ -312,11 +312,35 @@ pub struct ModuleState {
// Selección activa (qué muestra el canvas)
// =====================================================================
/// Identificador de una carta "libre" — efímera, no persistida en la
/// store. Llave de un `HashMap` en el shell. El valor `SKY_NOW_ID`
/// está reservado para la carta del instante actual; otros se
/// generan al vuelo como UUIDs string-encoded.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FreeChartId(pub String);
impl FreeChartId {
pub fn sky_now() -> Self {
Self(SKY_NOW_ID.into())
}
pub fn is_sky_now(&self) -> bool {
self.0 == SKY_NOW_ID
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Sentinela del id de la carta "Cielo ahora" — siempre presente
/// como primer elemento de la sección "Cartas libres" del tree.
pub const SKY_NOW_ID: &str = "sky-now";
/// Item activo del tree. El canvas reacciona a este tipo:
/// - `Chart` → abre la carta puntual.
/// - `Contact` / `Group` → muestra thumbnails de las cartas descendientes.
/// - `PresentSky` → carta efímera del instante presente (cielo ahora);
/// no vive en la store, se computa al vuelo cuando el host la pide.
/// - `FreeChart` → carta libre (no anclada a contacto). Incluye la
/// especial "Cielo ahora" + cualquier creada por el usuario.
/// - `FreeChartsRoot` → branch virtual de la sección "Cartas libres".
/// - `GeneralRoot` → nodo branch virtual que agrupa los contactos
/// sin grupo padre (contacts con parent=None).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -324,7 +348,8 @@ pub enum TreeSelection {
Group(GroupId),
Contact(ContactId),
Chart(ChartId),
PresentSky,
FreeChart(FreeChartId),
FreeChartsRoot,
GeneralRoot,
}