Componentes write-once (arquitectura compartida)La promesa central de la historia multi-destino de eQuantic.UI: los componentes se escriben una sola vez, en C#, contra un vocabulario abstracto, y se realizan según el destino: DOM + CSS en la web, píxeles de GPU a través de Photon en nativo. No "dos APIs parecidas", literalmente la misma clase. Esta página es la visión general de la arquitectura; el catálogo de componentes vive en la página Componentes, y cómo se ESCRIBE un árbol (factories en lugar de new, incluidos los de tus propios componentes) en Superficie declarativa. eQuantic.UI.Primitives
La base compartida: colores, tokens de diseño, estilos tipados, nodos abstractos, modelo de componentes
nada (cero)
eQuantic.UI.Components
La biblioteca de componentes write-once
solo Primitives
eQuantic.UI.Web
Realizador web: árbol abstracto → HtmlElement/DOM + CSS (SSR) + hoja de estilos generada
Core, Primitives
eQuantic.UI.Native.Components
Realizador nativo: árbol abstracto → display list de Photon
Primitives, Engine
Runtime TS (@equantic/runtime)
Lowering en el cliente (hidratación) + módulos de biblioteca embebidos
nada
Estructura: Box (+BoxStyle: pintura, opacidad, transform, aspect-ratio, diffs de hover/focus), Row/Column (flex con el gap como dueño + Wrap/RunGap), Grid (+GridTrack, el gemelo de CSS Grid), Stack/Positioned (orden z + Layer), AdaptiveNode (clases de tamaño de ventana, cero listeners), ScrollView (+Pinned), Overlay (capa del viewport, flag Modal), Anchored (paneles flotantes: emplazamientos, scrim al tocar fuera, MatchAnchorWidth, OpenOnHover). Contenido: Text (roles tipográficos), TextEntry, Icon (curados + cualquier glifo de un pack), Image, Spinner, Markdown (un documento renderizado como el design system), Mermaid (diagramas dibujados por él). Interacción y movimiento: Pressable (contrato de área táctil ≥48dp bajo un dedo, 24dp bajo un puntero), Link (anclas reales en la web, la costura de navegación del host en nativo), DragDismiss (el gesto de la hoja), LoopMotion, Presence (movimiento de entrada/salida). Más los tipos de valor (EdgeInsets, SizeValue Hug/Fill/Fixed/WindowMinus, CornerRadii, ColorToken con pares claro/oscuro, StyleDiff, Transform2D) y el modelo de componentes (StatelessComponent, StatefulComponent + SetState, ComponentContext sin modos). Los estilos no tienen plano CSS. Mira el motor de estilos atómico en DesignSystem: cada declaración de estilo se convierte en una clase atómica deduplicada, idéntica byte a byte entre SSR (C#) e hidratación (TS). La paridad de layout es un contrato: el motor flex de C# (nativo) y el flex de CSS (web) implementan la misma especificación: Flexible reparte el sobrante por peso, el contrato de truncado (el texto se encoge hasta la elipsis antes de empujar a sus hermanos), stretch rellena solo lo automático.
Desde 0.2.0-preview.6
Paneles responsivos: Flexible con una base
Flexible(child, flex, basis, shrink) es el trío flex: grow shrink basis de CSS. La base es el tamaño contra el que un padre que envuelve rompe las líneas, y es lo que hace que un layout responsivo sea expresable en absoluto:
// `Wrap` es una propiedad init, así que esta es la forma constructor + inicializador:
// las factories cubren constructores, nunca una segunda API.
var panes = new Row(gap: Space.S4) { Wrap = true, Width = SizeValue.Fill };
panes.Add(Flexible(EditorPane(), flex: 1, basis: 440));
panes.Add(Flexible(PreviewPane(), flex: 1, basis: 380));
Si hay ancho suficiente para las dos bases, los paneles comparten línea y se reparten el sobrante por peso. Si es demasiado estrecho, cada uno toma una línea propia y crece hasta llenarla, en lugar de quedarse en 440 con el resto de la línea vacío. Esa segunda parte es una pasada de verdad, no un efecto colateral del salto de línea: el sobrante va a los que crecen según su peso, de una línea que desborda se recupera espacio de los que se encogen ponderado por la base (como lo escala CSS), y nada baja del suelo de min-content.
Una base de 0, la opción por defecto, es el comportamiento histórico: el hijo no aporta nada propio y se dimensiona puramente por su peso. shrink: 0 se niega a devolver espacio, así que una línea que no cabe envuelve en vez de apretarse.
Cómo llega la misma clase a los dos destinos
Nativo: PhotonHost expande Build() en línea durante el layout y el realizador emite comandos de dibujo.
Web, servidor (SSR): el WebRealizer baja el árbol a HtmlElements. Los colores se vuelven light-dark(#l, #d) para que el DOM quede sin modo (cambiar de tema = color-scheme). Las páginas del Core pueden embeber subárboles write-once mediante el puente VisualNodeComponent, y un StatefulComponent de Primitives con [Page] es una página completa (el servidor lo puentea automáticamente).
Web, cliente: eqc (el compilador de C# a JS) transpila las mismas fuentes; el lowering del runtime (lowerVisualNode) refleja el WebRealizer regla por regla, y la paridad de hidratación se asegura con cadenas de estilo idénticas byte a byte, fijadas de forma cruzada y afirmadas tanto en la suite de C# como en la de TS.
Todos los destinos expanden un componente por la MISMA costura, y esa costura es una frontera: si Build lanza, el subárbol del componente que falló se sustituye por una superficie contenida y todo lo que lo rodea sigue renderizando. Una página que no tiene padre que la contenga lleva esa misma frontera en su propia costura de render, así que una página rota es un panel, nunca un documento en blanco.
Servidor (SSR)
500 para toda la petición
200, panel en lugar del subárbol
Navegador
el mount lanzó, la raíz nunca se escribió
panel, los hermanos interactivos
Ventana (Photon)
el frame nunca llegó
panel, la aplicación sigue presentando
El panel se construye con el vocabulario, así que es la misma superficie en una página y en una ventana. En desarrollo nombra el componente y cita la excepción; en producción dice solo que una sección no pudo mostrarse, porque un mensaje de excepción está escrito para quien escribió el código, y puede nombrar un id, una ruta o una consulta. En cualquier caso el fallo llega al log del host a través de ComponentBoundary.Report: una frontera que solo se traga las cosas cambia un fallo ruidoso por uno silencioso. Desde la 0.2.0-preview.49 el host Photon lo arma por defecto (tu logger si tienes uno, stderr si no), y una asignación hecha antes de que la aplicación arranque gana (??=): un report que ya tenías conectado sigue siendo tuyo, y una verificación que busca su marca literal la sigue encontrando.
Nada se recuerda. Un componente que deja de lanzar (un reintento, props nuevas, un hot reload) simplemente se construye otra vez en la siguiente pasada.
Desde 0.2.0-preview.55
Un componente cuyo Build se devuelve a sí mismo, o dos que se devuelven el uno al otro, queda contenido de la misma forma. Cada realizador expande lo que devolvió Build, así que un ciclo volvía a entrar en Build hasta desbordar la pila, y .NET no permite a nadie capturar una StackOverflowException: la petición, el frame o la ventana se iban con ella. Ahora la costura cuenta cuán profundo se anidan los componentes, y pasados 64 trata el recorrido como uno que no termina nunca: el mismo panel, la misma entrada en ComponentBoundary.Contained, el mismo report. Un árbol finito anidado más profundo que eso se ve idéntico desde dentro, así que el panel nombra lo que observó (un componente que sigue construyendo componentes después de 64 expansiones) en lugar de llamarlo un ciclo.
El CONTRATO propio de un componente no es un fallo de tiempo de render y no pertenece aquí: decláralo donde se escribe el error (init => field = value.Count > 3 ? throw … : value), para que quien lo escribe reciba una excepción en la línea que se equivocó y no un panel rojo una vez por frame.
La biblioteca la provee el runtime
Los módulos transpilados de eQuantic.UI.Components se entregan dentro de runtime.js, fijados byte a byte en CI contra la salida viva de eqc, y las aplicaciones los importan desde @equantic/runtime. Los componentes write-once escritos por quien usa el framework no necesitan ningún cableado: viven en la aplicación y pasan por el escaneo normal de páginas (eqc detecta las formas de componente de Primitives, incluido el stateful con SetState directo).
Generado, nunca escrito a mano
Cada valor del design system en el cliente se genera desde la única fuente en C# y se fija byte a byte:
•
PhotonCssGenerator → la hoja de estilos normativa (custom properties, clases de rol tipográfico, elevación, movimiento).
•
DesignSystemTsGenerator → design-system.generated.ts (tokens, tema, tabla de tamaños de Button).
•
IconTsGenerator → icons.generated.ts (datos de path de los glifos desde el IconRegistry de C#).
La biblioteca: una biblioteca, escrita una vez
Hay exactamente UNA biblioteca de componentes. Botones, tarjetas, inputs, navegación, overlays, listas, el ListView con reciclado, la cuadrícula Spreadsheet, el editor de código. El catálogo completo, agrupado y descrito, está en la página Componentes. Cada componente se entrega con pins del realizador web, goldens nativos (claro+oscuro), fixtures transpilados y fijados que se ejecutan en vitest, y el showroom en vivo (/ y /shared en DefaultUIDashboard: SSR + identidad de hidratación + interacción verificadas de punta a punta por la suite de Playwright). Sistemas que se entregan junto a la biblioteca: el sistema de movimiento por transición de estado (entrada/salida de Presence), la pipeline de puntero (hover, arrastrar para descartar con slop/cancel/glide), el compositor de scroll (anclado real, a través de Pinned) y los overlays anclados.
Un enlace dentro de una frase
Desde 0.2.0-preview.13
TextRun.Destination, porque un enlace en medio de un párrafo no tiene otro sitio donde vivir:
new Text("", TypeRole.BodyM)
new TextRun("Read the "),
new TextRun("getting started") { Destination = "/docs/start", Weight = FontWeight.SemiBold },
Un Link alrededor de todo el Text convierte el párrafo entero en un enlace. Una Row de Texts rompe entre runs y no entre palabras, así que una frase con tres fragmentos de código envuelve en los fragmentos, que es justo por lo que el énfasis mezclado tiene que ser un solo Text con Spans desde el principio. Entre esas dos, un enlace a mitad de frase era inexpresable, y para la prosa eso es la mayoría de los párrafos.
Un run también puede tener un TAMAÑO distinto de la prosa que lo rodea: StyleOverride en el run, la misma vía de escape que es Text.StyleOverride. Código en línea a 13.5 dentro de un párrafo a 16 es el caso, y el run conserva la caja de línea del párrafo: fijar su propio line-height abriría un hueco arriba y abajo, que es una línea, no un run. Desde 0.2.0-preview.15.
Un Text nunca es en línea por sí mismo: en la web es un bloque donde quiera que esté, con su propia caja de línea a la altura que diga su estilo tipográfico (desde 0.2.0-preview.61). Como span en línea dentro de un Box con padding se apoyaba en la línea de la caja, cuyo strut es la fuente del cuerpo de la página, y una etiqueta 10/15 en una píldora salía de 26.5px de alto cuando sus partes suman 21. Como bloque toma el ancho que le da su padre, igual que hace el Text de Flutter bajo un ancho estrecho, así que su alineación se lee a lo largo de ese ancho. Una frase es un Text con Spans: varios Texts dentro de un HtmlElement toman una línea cada uno.
La FORMA es universal: un NSAttributedString de Apple lleva un atributo .link en un rango, un Spannable de Android lleva un URLSpan. Un enlace en línea es un run con un atributo en todas partes, y no puede ser un nodo aparte, porque un nodo aparte es lo que rompe la línea.
El NOMBRE se sigue de la misma regla. Apple dice link, Android dice URLSpan, HTML dice href, así que href es la palabra de un destino, y la capa abstracta no habla el idioma de ningún destino (Pressable, no "Button"). Es Destination tanto en Link como en TextRun: lo que el valor ES, que es una ruta o una URL, en una palabra que ninguno de los tres destinos posee.
RENOMBRADO EN LA 0.2.0-PREVIEW.13, desde Href, en la misma versión en la que salió por primera vez. Un cambio incompatible tomado mientras es una línea por aplicación y no después de que haya aplicaciones que romper.
Spans se realiza hoy en la web. Photon realiza Link (recoge las regiones de enlace y les hace hit-test, entregando el destino a la costura de navegación del shell) pero no el destino por RUN, así que un párrafo con enlaces en línea se dibuja ahí como su texto, sin enlazar. Lo que falta es el hit-test por run, no el significado.
La ruta en la que está una página
Desde 0.2.0-preview.13
Una página en /docs/{slug} lee el slug de su propio contexto:
public sealed class DocPage : StatelessComponent, IServerPrefetch
public async Task PrefetchAsync(IServiceProvider services, CancellationToken cancellationToken)
=> _doc = await services.GetRequiredService<IDocs>()
.FindAsync(RouteValues.Current.Param("slug"), cancellationToken);
public override VisualNode Build(ComponentContext context) =>
Text(_doc?.Title ?? "Not found", TypeRole.Heading);
•
context.Route.Param("slug") en Build, RouteValues.Current.Param("slug") en un prefetch (que corre antes de que haya un contexto).
•
context.Route.Query("page") para la query string.
•
Nunca null; un nombre sin coincidencia responde null.
Antes de esto el único camino era IHttpContextAccessor. Funciona, y le cuesta a la página justo lo que la hace write-once: un componente que sabe de ASP.NET es un componente que no puede correr en Photon. Leer un parámetro de ruta es lo más corriente que hace una página, y si la manera de hacerlo es específica de la web, entonces "write-once" vale para lo visual y no para la página.
La ruta se arma antes del prefetch, y eso es lo que importa: una página en /docs/{slug} carga por el slug, así que una ruta que llega después del prefetch llega después de la única pregunta que venía a responder.
Por petición, en un AsyncLocal como cualquier otro valor por petición, porque el SSR renderiza peticiones concurrentes sobre instancias compartidas, y un slug guardado en un campo le entregaría el documento de un visitante a otro.
NECESITA 0.2.0-PREVIEW.14 PARA LEERSE DESDEBuild. En la preview.13 el runtime no exportaba RouteValues, así que una página que lo nombrara fuera de [ServerOnly] moría en la hidratación con "does not provide an export named 'RouteValues'", mientras el SSR seguía respondiendo 200 con marcado correcto, que es justo por lo que parecía que no pasaba nada. Leerlo desde un prefetch [ServerOnly] funciona en las dos.
Entrar en el árbol, y salir de él
Desde 0.2.0-preview.13
public sealed class LiveRates : StatefulComponent
private IDisposable? _subscription;
private NetworkState _network;
protected override void OnMount() =>
_subscription = _status.Subscribe(state => SetState(() => _network = state));
protected override void OnUnmount() => _subscription?.Dispose();
public override VisualNode Build(ComponentContext context) => …;
Un constructor no puede ser el lugar donde cargar estado guardado, suscribirse a un dispositivo o lanzar una petición: el reconciliador construye instancias nuevas en cada pasada y conserva la retenida, así que todo lo que arranca un constructor arranca otra vez para una instancia que luego se tira. OnMount corre una vez, en la instancia que se queda.
OnUnmount se entrega con él y no después, porque sin el segundo cada mount es una fuga.
Tres cosas que vale saber:
•
Se entrega cuando la pasada se cierra, no a mitad del build. Un hook que corriera dentro del reconcile correría mientras su propio padre todavía se construye, así que un SetState desde ahí mutaría un árbol a medio producir.
•
El que sale se desmonta antes de que el que entra se monte: el orden que permite a dos componentes compartir un recurso exclusivo a través de un cambio.
•
No significa "los píxeles existen". Photon no tiene DOM del que leer geometría, así que un hook que lo prometiera no podría ser write-once. Significa: este componente está en el árbol, su build está a punto de mostrarse, y un SetState desde aquí agenda la siguiente pasada en lugar de pelearse con la actual.
Una RAÍZ de página no tiene padre que la monte, así que lo hace la superficie: PhotonHost tras su primer frame, la base web en mount/hydrate.
Probar un árbol sin servidor
Un componente es un valor, así que afirmar sobre lo que renderiza cuesta un assert, no un proceso. El realizador web es público y los propios tests del framework usan exactamente esto:
using eQuantic.UI.Primitives;
var html = HtmlRenderer.RenderNode(
WebRealizer.Lower(new PriceTag(1999), PhotonTheme.Instance).Render());
html.Should().Contain("R$ 19,99");
Tres pasos, porque cada uno hace una cosa: Lower(node, theme) realiza el árbol, .Render() convierte el elemento realizado en un HtmlNode, y HtmlRenderer.RenderNode(...) escribe ese nodo como marcado. Pararse en .Render() te da el nodo, que es útil cuando la afirmación va sobre la estructura (.Tag, .Attributes) y no sobre el texto, que es lo que quieren la mayoría de los tests del propio framework. Sin un sumidero de estilos los estilos se quedan en línea, que es lo que quieres en un test: la forma de clase atómica es para la página, y afirmar sobre un hash de contenido es afirmar sobre un artefacto de build.
Dos cosas que esto da más allá de la velocidad. Un test puede renderizar el MISMO nodo para los dos realizadores y comparar qué hizo cada uno con él, que es cómo se sostiene el contrato de paridad de layout. Y un componente que lanza queda capturado por la frontera, así que un subárbol roto falla su propio assert en vez de llevarse la suite con él.