Repository navigation
WriteOnceComponents es
🌐 Esta página en: English · Português · Español · Français · Deutsch
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.
| Paquete | Papel | Depende de |
|---|---|---|
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
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.
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.
| antes | ahora | |
|---|---|---|
| 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.
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).
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 elIconRegistryde C#).
Mira DesignSystem.
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.
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)
{
Spans =
[
new TextRun("Read the "),
new TextRun("getting started") { Destination = "/docs/start", Weight = FontWeight.SemiBold },
new TextRun(" guide."),
],
}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.
Spansse realiza hoy en la web. Photon realizaLink(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.
Desde 0.2.0-preview.13
Una página en /docs/{slug} lee el slug de su propio contexto:
[Page("/docs/{slug}")]
public sealed class DocPage : StatelessComponent, IServerPrefetch
{
private Doc? _doc;
[ServerOnly]
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")enBuild,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 desde
Build. En la preview.13 el runtime no exportabaRouteValues, 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.
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
SetStatedesde 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
SetStatedesde 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.
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;
using eQuantic.UI.Web;
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.
🏁 Empieza aquí
🏗️ Arquitectura
- Visión general de la arquitectura
- Componentes write-once
- Superficie declarativa
- Arquitectura de paquetes
- Componentes
- Estilos
- Localización
- Analytics y GTM
📱 Write-once
- Motor Photon
- Design System
- Capacidades
- Almacenamiento
- Formularios
- Editor de código
- Markdown
- Mermaid
- Renderizado de email
⚙️ Compilación
⚡ Runtime
🔌 Servidor
🎨 Ecosistema
🚀 Desarrollo