feat(dev): issues 0100-0104 — dev_console binary + work_tab + DoD user-facing + frontmatter migration de 146 issues + taxonomia canonica

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 02:44:04 +02:00
parent 6ad82167bb
commit fad4006f60
164 changed files with 3934 additions and 323 deletions
@@ -0,0 +1,70 @@
---
name: export_hub_icons
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "export_hub_icons(out_dir: str, *, size: int = 64, registry_root: str | None = None) -> dict"
description: "Rasteriza iconos PNG para todas las apps C++/imgui del registry y los escribe en un directorio de salida. Consume registry.db para listar apps, lee icon.phosphor + icon.accent de cada app.md y usa la misma logica visual de generate_app_icon (fondo redondeado accent + glyph Phosphor blanco al 70%)."
tags: ["hub", "launcher", "icons", "suite", "png", "phosphor", "imgui", "cpp-windows"]
params:
- name: out_dir
desc: "Directorio de salida donde se escriben los <name>.png. Se crea si no existe. PNGs existentes se sobreescriben."
- name: size
desc: "Lado del cuadrado del PNG en pixels. Default 64. El hub launcher usa 64 para las tarjetas."
- name: registry_root
desc: "Ruta a la raiz del fn_registry. Si es None usa FN_REGISTRY_ROOT env o /home/lucas/fn_registry."
output: "dict con ok=True, count=N (PNGs escritos), out_dir (ruta absoluta), skipped (lista de {name, reason} para apps omitidas)."
uses_functions:
- generate_app_icon_py_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- cairosvg
- Pillow
- PyYAML
- sqlite3
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/export_hub_icons.py"
---
## Ejemplo
```bash
# Exportar iconos 64px al directorio local_files/icons del hub launcher (Windows via WSL)
python/.venv/bin/python3 -c "
import sys; sys.path.insert(0, 'python/functions')
from infra.export_hub_icons import export_hub_icons
print(export_hub_icons('/mnt/c/Users/lucas/Desktop/apps/app_hub_launcher/local_files/icons'))
"
# Exportar iconos a /tmp para smoke test
python/.venv/bin/python3 -c "
import sys; sys.path.insert(0, 'python/functions')
from infra.export_hub_icons import export_hub_icons
import json
print(json.dumps(export_hub_icons('/tmp/hub_icons_test'), indent=2))
"
# Via CLI directo con tamaño personalizado
cd /home/lucas/fn_registry
python/.venv/bin/python3 python/functions/infra/export_hub_icons.py /tmp/hub_icons --size 128
```
## Cuando usarla
Despues de compilar y desplegar `app_hub_launcher` a Windows Desktop, antes de lanzar la app: ejecutar esta funcion para generar los PNGs que el hub carga via `fn::gl_texture_load` al arrancar. Tambien util para regenerar los iconos tras cambiar el campo `icon:` en algun `app.md` o tras añadir una nueva app imgui al registry.
## Gotchas
- Requiere que `sources/phosphor-core/` este clonado en la raiz del registry (`git clone --depth=1 https://github.com/phosphor-icons/core.git sources/phosphor-core`). Si falta, todas las apps se omiten con "Phosphor SVG not found".
- Las apps sin campo `icon:` en su `app.md` usan el icono por defecto (`app-window`, accent `#64748b`) — no se omiten.
- `cairosvg` requiere `libcairo2` instalado en el sistema (`apt install libcairo2-dev` en Debian/Ubuntu). En WSL suele estar disponible si se uso anteriormente `generate_app_icon`.
- El campo `icon.phosphor` debe ser el nombre del glyph Phosphor **sin sufijo de weight** (ej. `"chart-bar"`, no `"chart-bar-fill"`). El sufijo `-fill.svg` se añade internamente.
- `out_dir` en rutas Windows desde WSL debe usar `/mnt/c/...` — no rutas Win32 directas.
- Apps con `dir_path` que apuntan a rutas fuera del registry (ej. `cpp/apps/shaders_lab`) se tratan igual: `root / dir_path / "app.md"`. Si la app no esta clonada localmente, su `app.md` falta y se omite.
+185
View File
@@ -0,0 +1,185 @@
"""export_hub_icons — rasteriza iconos PNG para todas las apps C++/imgui del registry.
Para cada app cpp/imgui registrada en registry.db lee el bloque `icon:` de su
app.md (phosphor + accent), rasteriza un PNG cuadrado usando la misma logica
visual que generate_app_icon y lo escribe en out_dir/<name>.png.
El directorio de salida lo consume app_hub_launcher via fn::gl_texture_load.
"""
from __future__ import annotations
import os
import sqlite3
import sys
from pathlib import Path
from typing import Any
from infra.generate_app_icon import _find_registry_root, _make_icon_image
DEFAULT_PHOSPHOR = "app-window"
DEFAULT_ACCENT = "#64748b"
DEFAULT_SIZE = 64
def _read_frontmatter(md_path: Path) -> dict[str, Any]:
"""Parse YAML frontmatter from a .md file. Returns {} on any error."""
try:
import yaml
text = md_path.read_text(encoding="utf-8")
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
if end == -1:
return {}
yaml_block = text[3:end].strip()
data = yaml.safe_load(yaml_block)
return data if isinstance(data, dict) else {}
except Exception as exc:
print(f"[export_hub_icons] WARN: could not parse {md_path}: {exc}", file=sys.stderr)
return {}
def export_hub_icons(
out_dir: str,
*,
size: int = DEFAULT_SIZE,
registry_root: str | None = None,
) -> dict:
"""Rasteriza iconos PNG para todas las apps C++/imgui del registry.
Consulta registry.db buscando apps con lang='cpp' AND framework='imgui',
lee el bloque icon: de cada app.md (phosphor + accent), y escribe un PNG
cuadrado de size x size pixels en out_dir/<name>.png usando el mismo estilo
visual de generate_app_icon: fondo redondeado del color accent con glyph
Phosphor blanco centrado al 70%.
Si una app.md no existe, no tiene icono declarado o el SVG Phosphor no se
encuentra, el app se omite (skipped) y el proceso continua con las demas.
Args:
out_dir: Directorio de salida. Se crea si no existe. Los PNGs existentes
se sobreescriben.
size: Lado del cuadrado del PNG en pixels. Default 64.
registry_root: Ruta a la raiz del fn_registry. Si es None, usa la variable
de entorno FN_REGISTRY_ROOT o /home/lucas/fn_registry como fallback.
Returns:
{
"ok": True,
"count": N, # PNGs escritos correctamente
"out_dir": "<abs>", # ruta absoluta del directorio de salida
"skipped": [ # apps omitidas con razon
{"name": "...", "reason": "..."},
...
]
}
"""
root = _find_registry_root(registry_root)
db_path = root / "registry.db"
if not db_path.exists():
raise FileNotFoundError(f"registry.db not found at {db_path}")
con = sqlite3.connect(str(db_path))
con.row_factory = sqlite3.Row
try:
rows = con.execute(
"SELECT id, name, dir_path FROM apps"
" WHERE lang='cpp' AND framework='imgui'"
" ORDER BY name"
).fetchall()
finally:
con.close()
phosphor_assets = root / "sources" / "phosphor-core" / "assets"
out_path = Path(out_dir).resolve()
out_path.mkdir(parents=True, exist_ok=True)
count = 0
skipped: list[dict[str, str]] = []
for row in rows:
app_name: str = row["name"]
dir_path: str = row["dir_path"]
# --- Leer app.md ---
md_path = root / dir_path / "app.md"
if not md_path.exists():
msg = f"app.md missing at {md_path}"
print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr)
skipped.append({"name": app_name, "reason": msg})
continue
fm = _read_frontmatter(md_path)
if not fm:
msg = "app.md missing or malformed frontmatter"
print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr)
skipped.append({"name": app_name, "reason": msg})
continue
icon_block = fm.get("icon")
if isinstance(icon_block, dict):
phosphor_name = icon_block.get("phosphor") or DEFAULT_PHOSPHOR
accent_hex = icon_block.get("accent") or DEFAULT_ACCENT
else:
phosphor_name = DEFAULT_PHOSPHOR
accent_hex = DEFAULT_ACCENT
# --- Localizar SVG Phosphor ---
svg_path = phosphor_assets / "fill" / f"{phosphor_name}-fill.svg"
if not svg_path.exists():
msg = f"Phosphor SVG not found: {svg_path}"
print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr)
skipped.append({"name": app_name, "reason": msg})
continue
# --- Rasterizar y guardar PNG ---
try:
img = _make_icon_image(svg_path, accent_hex, size)
png_out = out_path / f"{app_name}.png"
img.save(str(png_out), format="PNG")
count += 1
except Exception as exc:
msg = f"render error: {exc}"
print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr)
skipped.append({"name": app_name, "reason": msg})
continue
return {
"ok": True,
"count": count,
"out_dir": str(out_path),
"skipped": skipped,
}
if __name__ == "__main__":
import argparse
import json
parser = argparse.ArgumentParser(
description="Export PNG icons for all cpp/imgui apps to a directory."
)
parser.add_argument("out_dir", help="Destination directory for .png files")
parser.add_argument(
"--size",
type=int,
default=DEFAULT_SIZE,
help=f"Icon side in pixels (default {DEFAULT_SIZE})",
)
parser.add_argument(
"--registry-root",
default=None,
help="Path to fn_registry root (default: FN_REGISTRY_ROOT env or /home/lucas/fn_registry)",
)
args = parser.parse_args()
result = export_hub_icons(
args.out_dir,
size=args.size,
registry_root=args.registry_root,
)
print(json.dumps(result, indent=2))
+43 -6
View File
@@ -2,6 +2,11 @@
Rasteriza un icono Phosphor SVG sobre un fondo redondeado del color accent
y exporta un .ico con multiples resoluciones (16, 24, 32, 48, 64, 128, 256).
Funciones publicas reutilizables:
_find_registry_root — localiza la raiz del registry
_render_glyph_white — rasteriza un SVG Phosphor como glyph blanco RGBA
_make_icon_image — compone fondo redondeado + glyph en un PIL.Image
"""
import io
import os
@@ -14,8 +19,23 @@ from PIL import Image, ImageDraw
DEFAULT_SIZES = [16, 24, 32, 48, 64, 128, 256]
def _find_registry_root() -> Path:
"""Descubre la raiz del registry caminando hacia arriba hasta encontrar registry.db."""
def _find_registry_root(registry_root: str | None = None) -> Path:
"""Descubre la raiz del registry.
Prioridad: argumento explicito > FN_REGISTRY_ROOT env > caminar hacia arriba
desde el archivo hasta encontrar registry.db.
Args:
registry_root: Ruta absoluta opcional. Si se pasa, se devuelve directamente.
Returns:
Path resuelto a la raiz del registry.
Raises:
FileNotFoundError: Si no se puede localizar registry.db.
"""
if registry_root is not None:
return Path(registry_root).resolve()
env_root = os.environ.get("FN_REGISTRY_ROOT")
if env_root:
return Path(env_root).resolve()
@@ -35,7 +55,15 @@ def _hex_to_rgb(h: str) -> tuple[int, int, int]:
def _render_glyph_white(svg_path: Path, size: int) -> Image.Image:
"""Renderiza el SVG Phosphor como glyph blanco sobre fondo transparente."""
"""Renderiza el SVG Phosphor como glyph blanco sobre fondo transparente.
Args:
svg_path: Ruta al archivo .svg de Phosphor.
size: Ancho/alto en pixels del output.
Returns:
Imagen RGBA con el glyph en blanco sobre fondo transparente.
"""
svg = svg_path.read_text(encoding="utf-8")
# Phosphor usa fill="currentColor" — forzar blanco.
svg = svg.replace('fill="currentColor"', 'fill="#ffffff"')
@@ -48,7 +76,16 @@ def _render_glyph_white(svg_path: Path, size: int) -> Image.Image:
def _make_icon_image(svg_path: Path, accent_hex: str, size: int) -> Image.Image:
"""Compone fondo redondeado con color accent + glyph blanco centrado al 70%."""
"""Compone fondo redondeado con color accent + glyph blanco centrado al 70%.
Args:
svg_path: Ruta al archivo .svg de Phosphor.
accent_hex: Color de fondo en formato "#RRGGBB".
size: Ancho/alto en pixels del canvas de salida.
Returns:
Imagen RGBA lista para guardar como PNG o ICO.
"""
bg_color = _hex_to_rgb(accent_hex) + (255,)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
@@ -113,8 +150,8 @@ def generate_app_icon(
# Resolver raiz de phosphor
if phosphor_root is None:
registry_root = _find_registry_root()
phosphor_assets = registry_root / "sources" / "phosphor-core" / "assets"
_root = _find_registry_root()
phosphor_assets = _root / "sources" / "phosphor-core" / "assets"
else:
phosphor_assets = Path(phosphor_root)