chore: snapshot WIP previo + flow 0008 + 7 sub-issues (0112-0119)

Snapshot de WIP acumulado de sesiones previas antes de merge wave 1
del flow 0008 (kanban_cpp + agent_runner_api + DoD schema).

Incluye:
- dev/flows/0008-kanban-cpp-and-agent-workflows.md
- dev/issues/0112-0119*.md (7 sub-issues)
- WIP previo en cmd/fn/doctor.go, registry/*, modules/, cpp/, etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:17:08 +02:00
parent ddb5366884
commit b9716a7cd6
119 changed files with 14929 additions and 3084 deletions
@@ -74,6 +74,16 @@ def generate(app_md: Path, modules_root: Path, app_name: str, out_path: Path) ->
if not isinstance(uses_modules, list):
uses_modules = []
# Toda app C++ procesada por add_imgui_app enlaza fn_framework. Inyectamos
# framework_cpp implicito si la app es C++ y no lo declara explicitamente.
# Asi el array embebido refleja la version real del framework linkeado,
# sin pedir a cada app.md que lo declare a mano.
lang = str(fm.get("lang", "")).lower()
if lang == "cpp":
already = [str(m) for m in uses_modules]
if not any(m.startswith("framework_") for m in already):
uses_modules = ["framework_cpp"] + already
entries: list[dict] = []
missing: list[str] = []
for mid in uses_modules:
@@ -107,6 +117,20 @@ def generate(app_md: Path, modules_root: Path, app_name: str, out_path: Path) ->
else:
lines.append("const ModuleInfo app_modules_array[1] = { { nullptr, nullptr, nullptr } };")
lines.append("const unsigned long app_modules_count = 0;")
# Header badge identity — auto-derivado del bloque `icon:` del app.md.
# Permite que el framework muestre el badge accent en viewports secundarios
# sin tocar main.cpp. Coherente con App Hub (mismo hex que la tarjeta).
icon_block = fm.get("icon") or {}
accent_hex = str(icon_block.get("accent", "") or "")
glyph_name = str(icon_block.get("phosphor", "") or "")
lines.append(
f'const char* const app_header_accent_hex = "{_escape_c_string(accent_hex)}";'
)
lines.append(
f'const char* const app_header_glyph_name = "{_escape_c_string(glyph_name)}";'
)
lines.append("} // namespace fn")
lines.append("")
+5 -3
View File
@@ -3,10 +3,10 @@ name: export_hub_icons
kind: function
lang: py
domain: infra
version: "1.0.0"
version: "1.1.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%)."
signature: "export_hub_icons(out_dir: str, *, size: int = 64, registry_root: str | None = None, style: str = 'fill_white') -> 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 logica visual de generate_app_icon. Param `style` soporta 'fill_white' (default, glyph blanco solido), 'adaptive_duotone' y 'white_duotone' (glyph duotone Phosphor — alineado con generate_app_icon)."
tags: ["hub", "launcher", "icons", "suite", "png", "phosphor", "imgui", "cpp-windows"]
params:
- name: out_dir
@@ -15,6 +15,8 @@ params:
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."
- name: style
desc: "Estilo del icono igual que generate_app_icon: 'fill_white' (default), 'adaptive_duotone' o 'white_duotone'. CLI: `--style <valor>`."
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
+41 -9
View File
@@ -15,11 +15,19 @@ import sys
from pathlib import Path
from typing import Any
from infra.generate_app_icon import _find_registry_root, _make_icon_image
from infra.generate_app_icon import (
_find_registry_root,
_make_icon_image,
_make_icon_image_adaptive,
_make_icon_image_duotone,
WHITE_DUOTONE_GLYPH,
)
DEFAULT_PHOSPHOR = "app-window"
DEFAULT_ACCENT = "#64748b"
DEFAULT_SIZE = 64
DEFAULT_STYLE = "fill_white"
VALID_STYLES = ("fill_white", "adaptive_duotone", "white_duotone")
def _read_frontmatter(md_path: Path) -> dict[str, Any]:
@@ -46,6 +54,7 @@ def export_hub_icons(
*,
size: int = DEFAULT_SIZE,
registry_root: str | None = None,
style: str = DEFAULT_STYLE,
) -> dict:
"""Rasteriza iconos PNG para todas las apps C++/imgui del registry.
@@ -76,6 +85,9 @@ def export_hub_icons(
]
}
"""
if style not in VALID_STYLES:
raise ValueError(f"style invalido: {style!r}. Valores: {' | '.join(VALID_STYLES)}")
root = _find_registry_root(registry_root)
db_path = root / "registry.db"
@@ -128,17 +140,30 @@ def export_hub_icons(
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
# --- Localizar SVG Phosphor (duotone preferido si style lo necesita) ---
svg_fill = phosphor_assets / "fill" / f"{phosphor_name}-fill.svg"
svg_duo = phosphor_assets / "duotone" / f"{phosphor_name}-duotone.svg"
if style == "fill_white":
if not svg_fill.exists():
msg = f"Phosphor SVG not found: {svg_fill}"
print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr)
skipped.append({"name": app_name, "reason": msg})
continue
else:
if not svg_duo.exists() and not svg_fill.exists():
msg = f"Phosphor SVG not found: {svg_duo} ni {svg_fill}"
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)
if style == "fill_white":
img = _make_icon_image(svg_fill, accent_hex, size)
elif style == "adaptive_duotone":
img = _make_icon_image_adaptive(phosphor_name, accent_hex, size, phosphor_assets)
else: # white_duotone
img = _make_icon_image_duotone(phosphor_name, accent_hex, size, phosphor_assets, WHITE_DUOTONE_GLYPH)
png_out = out_path / f"{app_name}.png"
img.save(str(png_out), format="PNG")
count += 1
@@ -175,11 +200,18 @@ if __name__ == "__main__":
default=None,
help="Path to fn_registry root (default: FN_REGISTRY_ROOT env or /home/lucas/fn_registry)",
)
parser.add_argument(
"--style",
default=DEFAULT_STYLE,
choices=VALID_STYLES,
help=f"Estilo del icono (default {DEFAULT_STYLE})",
)
args = parser.parse_args()
result = export_hub_icons(
args.out_dir,
size=args.size,
registry_root=args.registry_root,
style=args.style,
)
print(json.dumps(result, indent=2))
+7 -4
View File
@@ -3,10 +3,10 @@ name: generate_app_icon
kind: function
lang: py
domain: infra
version: "1.0.0"
version: "1.2.0"
purity: impure
signature: "generate_app_icon(phosphor_icon_name: str, accent_hex: str, out_ico_path: str, *, weight: str = 'fill', sizes: list[int] = None, phosphor_root: str = None) -> str"
description: "Rasteriza un icono Phosphor SVG sobre un fondo redondeado del color accent y exporta un .ico multi-resolucion (default 16,24,32,48,64,128,256). Devuelve el path absoluto del .ico escrito. El glyph se renderiza en blanco al 70% del canvas sobre fondo con esquinas redondeadas al 16%."
signature: "generate_app_icon(phosphor_icon_name: str, accent_hex: str, out_ico_path: str, *, weight: str = 'fill', sizes: list[int] = None, phosphor_root: str = None, style: str = 'fill_white') -> str"
description: "Rasteriza un icono Phosphor SVG sobre un fondo redondeado del color accent y exporta un .ico multi-resolucion (default 16,24,32,48,64,128,256). Soporta tres estilos via param `style`: 'fill_white' (default, glyph blanco solido), 'adaptive_duotone' (glyph duotone con tono claro u oscuro segun luminancia del accent) y 'white_duotone' (glyph duotone phosphor con fill blanco — el path bg al opacity=0.2 deja translucir el accent dando segundo tono suave). Devuelve el path absoluto del .ico escrito."
tags: [cpp-windows, icon, windows, phosphor, ico, pillow, cairosvg]
uses_functions: []
uses_types: []
@@ -27,6 +27,8 @@ params:
desc: "Lista de resoluciones a incluir. Default [16,24,32,48,64,128,256]. Cada tamano se renderiza independientemente para crispness."
- name: phosphor_root
desc: "Carpeta raiz de assets phosphor-core (contiene subdirs fill/, regular/, etc.). Default: <registry_root>/sources/phosphor-core/assets."
- name: style
desc: "Estilo de render. 'fill_white' (default, glyph blanco solido — backwards-compat). 'adaptive_duotone' (glyph duotone Phosphor con tono claro #f4f4f5 si luminancia(accent)<0.5, tono oscuro #0f0f12 si >=0.5; el path opacity=0.2 da el segundo tono). 'white_duotone' (glyph duotone Phosphor con fill #ffffff; path opacity=0.2 deja translucir el accent dando segundo tono suave; legible en cualquier accent claro u oscuro)."
output: "Ruta absoluta (str) del archivo .ico generado y escrito a disco."
tested: false
tests: []
@@ -77,4 +79,5 @@ Cuando una app C++ del registry necesita un `.ico` de Windows para distinguirse
## Capability growth log
*(sin cambios desde v1.0.0)*
- v1.2.0 (2026-05-18) — añade `style="white_duotone"`. Glyph duotone Phosphor con fill blanco; path bg al opacity=0.2 deja translucir el accent. Mas suave que `fill_white`, mismo punch que `adaptive_duotone` pero sin condicional.
- v1.1.0 (2026-05-17) — añade param `style="adaptive_duotone"`. Glyph duotone con tono claro/oscuro adaptativo segun luminancia del accent (Rec.601). Compatibilidad total con `style="fill_white"` por default.
+117 -13
View File
@@ -54,6 +54,24 @@ def _hex_to_rgb(h: str) -> tuple[int, int, int]:
return tuple(int(h[i: i + 2], 16) for i in (0, 2, 4))
def _luminance(accent_hex: str) -> float:
"""Luminancia percibida Rec.601 normalizada [0,1]."""
r, g, b = _hex_to_rgb(accent_hex)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
def _render_glyph_colored(svg_path: Path, size: int, fill: str) -> Image.Image:
"""Renderiza un SVG Phosphor reemplazando currentColor por `fill`."""
svg = svg_path.read_text(encoding="utf-8")
svg = svg.replace('fill="currentColor"', f'fill="{fill}"')
png_bytes = cairosvg.svg2png(
bytestring=svg.encode("utf-8"),
output_width=size,
output_height=size,
)
return Image.open(io.BytesIO(png_bytes)).convert("RGBA")
def _render_glyph_white(svg_path: Path, size: int) -> Image.Image:
"""Renderiza el SVG Phosphor como glyph blanco sobre fondo transparente.
@@ -105,6 +123,59 @@ def _make_icon_image(svg_path: Path, accent_hex: str, size: int) -> Image.Image:
return canvas
ADAPTIVE_LUMINANCE_THRESHOLD = 0.5
ADAPTIVE_DARK_GLYPH = "#0f0f12"
ADAPTIVE_LIGHT_GLYPH = "#f4f4f5"
WHITE_DUOTONE_GLYPH = "#ffffff"
def _make_icon_image_duotone(
phosphor_icon_name: str,
accent_hex: str,
size: int,
phosphor_assets: Path,
glyph_color: str,
) -> Image.Image:
"""Compone bg accent + glyph duotone Phosphor con `glyph_color` como fill.
Usa la variante `duotone` de Phosphor cuando exista (el path con `opacity=0.2`
crea automaticamente el segundo tono atenuado al mezclarse con el accent).
Fallback a `fill` si no hay duotone.
"""
bg_color = _hex_to_rgb(accent_hex) + (255,)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
radius = max(2, size // 6)
draw.rounded_rectangle([(0, 0), (size - 1, size - 1)], radius=radius, fill=bg_color)
glyph_size = int(size * 0.7)
if glyph_size < 8:
glyph_size = max(8, size - 2)
svg_duo = phosphor_assets / "duotone" / f"{phosphor_icon_name}-duotone.svg"
svg_fill = phosphor_assets / "fill" / f"{phosphor_icon_name}-fill.svg"
svg_src = svg_duo if svg_duo.exists() else svg_fill
if not svg_src.exists():
raise FileNotFoundError(f"Icono Phosphor no encontrado: {svg_src}")
glyph = _render_glyph_colored(svg_src, glyph_size, glyph_color)
off = ((size - glyph_size) // 2, (size - glyph_size) // 2)
canvas.alpha_composite(glyph, dest=off)
return canvas
def _make_icon_image_adaptive(
phosphor_icon_name: str,
accent_hex: str,
size: int,
phosphor_assets: Path,
threshold: float = ADAPTIVE_LUMINANCE_THRESHOLD,
) -> Image.Image:
"""Adaptive duotone: tono claro/oscuro segun luminancia del accent."""
glyph_color = ADAPTIVE_DARK_GLYPH if _luminance(accent_hex) >= threshold else ADAPTIVE_LIGHT_GLYPH
return _make_icon_image_duotone(phosphor_icon_name, accent_hex, size, phosphor_assets, glyph_color)
def generate_app_icon(
phosphor_icon_name: str,
accent_hex: str,
@@ -113,6 +184,7 @@ def generate_app_icon(
weight: str = "fill",
sizes: list[int] = None,
phosphor_root: str = None,
style: str = "fill_white",
) -> str:
"""Genera un icono .ico multi-resolucion a partir de un SVG Phosphor.
@@ -137,17 +209,32 @@ def generate_app_icon(
phosphor_root: Ruta a la carpeta raiz de assets de phosphor-core
(la que contiene subdirectorios "fill", "regular", etc.).
Default: <registry_root>/sources/phosphor-core/assets.
style: Estilo de render. Valores:
- "fill_white" (default, backwards-compat): fondo accent + glyph
blanco solido usando `weight`.
- "adaptive_duotone": fondo accent + glyph duotone con tono claro/
oscuro elegido por luminancia del accent. Glyph oscuro
`#0f0f12` si L>=0.5; glyph claro `#f4f4f5` si L<0.5. Usa
variante `duotone` de Phosphor (el path con opacity=0.2 da el
segundo tono). Fallback a `fill` si no hay duotone.
- "white_duotone": fondo accent + glyph duotone Phosphor con fill
`#ffffff`. Path bg al opacity=0.2 deja translucir el accent y
produce un segundo tono mas suave. Legible en cualquier accent
(claro u oscuro). Fallback a `fill` si no hay duotone.
Returns:
Ruta absoluta del archivo .ico generado.
Raises:
FileNotFoundError: Si el SVG del icono no existe en phosphor_root.
ValueError: Si accent_hex no tiene el formato "#RRGGBB".
ValueError: Si accent_hex no tiene el formato "#RRGGBB" o style invalido.
"""
if sizes is None:
sizes = DEFAULT_SIZES
if style not in ("fill_white", "adaptive_duotone", "white_duotone"):
raise ValueError(f"style invalido: {style!r}. Valores: fill_white | adaptive_duotone | white_duotone")
# Resolver raiz de phosphor
if phosphor_root is None:
_root = _find_registry_root()
@@ -155,22 +242,39 @@ def generate_app_icon(
else:
phosphor_assets = Path(phosphor_root)
svg_file = phosphor_assets / weight / f"{phosphor_icon_name}-{weight}.svg"
if not svg_file.exists():
raise FileNotFoundError(
f"Icono Phosphor no encontrado: {svg_file}\n"
f"Asegurate de que sources/phosphor-core/ existe. Si no:\n"
f" git clone --depth=1 https://github.com/phosphor-icons/core.git "
f"sources/phosphor-core"
)
# Validar formato del color
# Validar formato del color (antes de tocar disco)
h = accent_hex.lstrip("#")
if len(h) != 6:
raise ValueError(f"accent_hex debe tener formato #RRGGBB, recibido: {accent_hex!r}")
# Renderizar cada resolucion individualmente para crispness en tamanos pequeños
images = {s: _make_icon_image(svg_file, accent_hex, s) for s in sorted(sizes, reverse=True)}
if style in ("adaptive_duotone", "white_duotone"):
svg_duo = phosphor_assets / "duotone" / f"{phosphor_icon_name}-duotone.svg"
svg_fill = phosphor_assets / "fill" / f"{phosphor_icon_name}-fill.svg"
if not svg_duo.exists() and not svg_fill.exists():
raise FileNotFoundError(
f"Icono Phosphor no encontrado: {svg_duo} ni {svg_fill}\n"
f"Asegurate de que sources/phosphor-core/ existe."
)
if style == "adaptive_duotone":
images = {
s: _make_icon_image_adaptive(phosphor_icon_name, accent_hex, s, phosphor_assets)
for s in sorted(sizes, reverse=True)
}
else: # white_duotone
images = {
s: _make_icon_image_duotone(phosphor_icon_name, accent_hex, s, phosphor_assets, WHITE_DUOTONE_GLYPH)
for s in sorted(sizes, reverse=True)
}
else:
svg_file = phosphor_assets / weight / f"{phosphor_icon_name}-{weight}.svg"
if not svg_file.exists():
raise FileNotFoundError(
f"Icono Phosphor no encontrado: {svg_file}\n"
f"Asegurate de que sources/phosphor-core/ existe. Si no:\n"
f" git clone --depth=1 https://github.com/phosphor-icons/core.git "
f"sources/phosphor-core"
)
images = {s: _make_icon_image(svg_file, accent_hex, s) for s in sorted(sizes, reverse=True)}
out = Path(out_ico_path)
if not out.is_absolute():
@@ -3,10 +3,10 @@ name: regenerate_app_icons
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
version: "1.2.0"
purity: impure
signature: "def regenerate_app_icons(only: list[str] | None = None) -> dict"
description: "Escanea todas las apps C++ del registry, lee el bloque `icon: {phosphor, accent}` de cada app.md y regenera el appicon.ico via generate_app_icon. Reemplaza el script ad-hoc dev/gen_app_icons.py."
signature: "def regenerate_app_icons(only: list[str] | None = None, style: str = 'fill_white') -> dict"
description: "Escanea todas las apps C++ del registry, lee el bloque `icon: {phosphor, accent}` de cada app.md y regenera el appicon.ico via generate_app_icon. Soporta param `style` ('fill_white' default | 'adaptive_duotone' | 'white_duotone' bg accent + glyph duotone Phosphor con fill blanco). CLI flags: `--adaptive`, `--white`, o `--style=<valor>`. Reemplaza el script ad-hoc dev/gen_app_icons.py."
tags: [cpp-windows, icon, phosphor, batch]
uses_functions: [generate_app_icon_py_infra]
uses_types: []
@@ -17,6 +17,8 @@ imports: [os, sys, pathlib, typing, yaml]
params:
- name: only
desc: "Lista opcional de nombres de app (campo `name` del frontmatter) a procesar. Si None, regenera todas las apps C++ con icon: declarado."
- name: style
desc: "'fill_white' (default, glyph blanco), 'adaptive_duotone' (bg accent + duotone con tono claro/oscuro adaptativo) o 'white_duotone' (bg accent + duotone Phosphor blanco). CLI: `--adaptive`, `--white`, o `--style=<valor>`."
output: "dict {ok: [name], skipped: [{name, reason}], failed: [{name, error}]}"
tested: false
tests: []
@@ -27,14 +29,14 @@ file_path: "python/functions/pipelines/regenerate_app_icons.py"
## Ejemplo
```bash
# Regenerar todas las apps C++ con icon: declarado
# Regenerar todas las apps C++ con icon: declarado (estilo clasico)
./fn run regenerate_app_icons
# Estilo adaptive_duotone (dark/light glyph segun luminancia del accent)
python/.venv/bin/python3 python/functions/pipelines/regenerate_app_icons.py --adaptive
# Solo una app
./fn run regenerate_app_icons chart_demo
# Varias apps
./fn run regenerate_app_icons chart_demo registry_dashboard
```
```python
@@ -45,12 +45,18 @@ def _iter_cpp_app_mds(root: Path):
yield md, fm
def regenerate_app_icons(only: Optional[list[str]] = None) -> dict:
def regenerate_app_icons(
only: Optional[list[str]] = None,
style: str = "fill_white",
) -> dict:
"""Recorre apps C++ con bloque icon: en su frontmatter y regenera appicon.ico.
Args:
only: Lista opcional de nombres de app a filtrar (campo `name`). Si None,
procesa todas las apps C++ con `icon:` declarado.
style: Estilo del icono. "fill_white" (default, glyph blanco) o
"adaptive_duotone" (glyph duotone con tono claro/oscuro segun
luminancia del accent). Ver generate_app_icon.
Returns:
dict con keys: ok (list[str]), skipped (list[dict]), failed (list[dict]).
@@ -77,6 +83,7 @@ def regenerate_app_icons(only: Optional[list[str]] = None) -> dict:
phosphor_icon_name=phosphor,
accent_hex=accent,
out_ico_path=str(out_ico),
style=style,
)
ok.append(name)
except Exception as e:
@@ -86,8 +93,20 @@ def regenerate_app_icons(only: Optional[list[str]] = None) -> dict:
if __name__ == "__main__":
only = sys.argv[1:] or None
result = regenerate_app_icons(only=only)
args = sys.argv[1:]
style = "fill_white"
only = []
for a in args:
if a.startswith("--style="):
style = a.split("=", 1)[1]
elif a == "--adaptive":
style = "adaptive_duotone"
elif a == "--white":
style = "white_duotone"
else:
only.append(a)
only = only or None
result = regenerate_app_icons(only=only, style=style)
for name in result["ok"]:
print(f"OK {name}")
for s in result["skipped"]: