eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsArquitectura
Arquitectura de estilos de eQuantic.UI
Edit this page
18 min read
Filosofía central
eQuantic.UI ofrece una experiencia de desarrollo (DX) inspirada en Flutter mientras aprovecha toda la potencia de cada destino de renderizado. La arquitectura de estilos se apoya en tres pilares:
1.
Abstracción: los componentes escriben _qué_ estilizar, como valores tipados de C#, nunca cadenas de CSS.
2.
Una semántica, dos destinos: el mismo vocabulario de estilos conduce valores dp en nativo (Photon) y CSS en la web.
3.
Rendimiento: generación y deduplicación de clases en tiempo de build, sin sobrecoste de un runtime de CSS-in-JS.
1. Estilizar componentes: C# tipado, sin plano CSS
El estilo de los componentes se escribe enteramente en C# tipado (BoxStyle, StyleDiff, tokens de diseño) y lo baja el motor de estilos atómico: cada declaración corriente se convierte en UNA clase atómica deduplicada, idéntica byte a byte entre SSR (C#) e hidratación (TS); los colores del tema referencian custom properties var(--eq-color-*). Los estados de hover y focus, la adaptabilidad por clase de tamaño de ventana, el posicionamiento sticky y los transforms son todos parte del vocabulario, sin nada de JavaScript involucrado.
El hover es derivado, y es solo de puntero
Desde 0.2.0-preview.24
La paleta de variantes se queda en cinco sub-tokens; el hover se resuelve por DERIVACIÓN: VariantColors.Hover es el punto medio por canal de Base → Pressed (ColorToken.MidpointWith, el mismo color en el que aterriza color-mix(in srgb, Base 50%, Pressed)), y las variantes discretas hacen hover sobre SurfaceSubtle. Los componentes lo escriben como un diff BoxStyle.Hover corriente, así que los dos realizadores ya sabían pintarlo: la web como una regla atómica :hover, Photon a través de la pipeline de puntero.
Dos reglas van en la emisión misma. Cada regla atómica :hover se envuelve en @media (hover: hover), así que el hover emulado y pegajoso de un navegador táctil no encuentra ninguna regla, y la puerta envuelve la regla, nunca el hash, así que SSR e hidratación siguen comparando cadenas de clase idénticas. Y en Photon el hover se rastrea por RUTA de layout como una CADENA de todo lo que está bajo el puntero, porque el :hover de CSS coincide con cada ancestro: hacer hover sobre el disparador de un tooltip hace hover sobre el tooltip también, y una reconstrucción de componente, que reemplaza cada instancia de nodo, ya no pierde el hover que repintó.
Un color que un componente deriva corre en el navegador
Desde 0.2.0-preview.60
El navegador guarda un Color como datos planos, { r, g, b, a }, porque los estilos del runtime, el design system generado y un payload de hidratación lo construyen todos sin un constructor. El código del lado del navegador de un componente sigue escribiéndolo como C#: Brand.WithOpacity(0.8f), a.MidpointWith(b), new Color(…), default(Color), $"{color}", y un grupo de métodos como Func<float, Color> fade = Brand.WithOpacity. eqc baja cada uno al Color del runtime, y el texto es el texto de record que escribe .NET, Color { R = 248, G = 113, B = 113, A = 204 }.
Antes de la 0.2.0-preview.60 estos se renderizaban en el servidor y lanzaban en el navegador una vez que la página hidrataba (withOpacity is not a function), y un tipo propio de la aplicación llamado Color se construía como el del vocabulario.
Una curva que un componente hace corre en el navegador
Desde 0.2.0-preview.61
Una Curve también es dato plano en el navegador, { x1, y1, x2, y2 }, donde quiera que el navegador se encuentre una: un preset, la curva de un rol de movimiento, el easing de una transición, una curva que hace un componente, una curva en el estado de una página. Así que el código del lado del navegador de un componente lee y hace una curva como lo hace C#: Curve.Standard.X1, Motion.Press.Curve, new Curve(0.2f, 0.9f, 0.3f, 1.25f) como Easing de un TransitionSpec, ==, with, una deconstrucción, y $"{curve}", que imprime Curve { X1 = 0.2, Y1 = 0, X2 = 0, Y2 = 1 } como hace .NET.
1
2
3
4
5
var easing = new Curve(0.2f, 0.9f, 0.3f, 1.25f);
var style = new BoxStyle
{
Transition = new TransitionSpec(StyleChannels.Colors, 150) { Easing = easing },
};
El evaluador se queda en el servidor y en los shells nativos. Una transición web es una función de temporización de CSS, así que el navegador evalúa la curva él mismo, y un componente que llama a curve.Ease(t) o a CurveEvaluator.Ease(curve, t) falla el build con EQ2010. Entrega la curva a un TransitionSpec, o llama al evaluador desde código de servidor.
Antes de la 0.2.0-preview.61 un preset era un array en el navegador y la curva de un rol de movimiento un nombre, así que un punto leía undefined, new Curve(…) lanzaba "is not a constructor" una vez que la página hidrataba, y curve.Ease(t) construía y lanzaba.
Un estado cambia cualquier miembro del estilo
Desde 0.2.0-preview.60
Un StyleDiff es el aspecto que tiene una caja mientras un estado está activo: BoxStyle.Hover bajo el puntero, y BoxStyle.Focus y BoxStyle.Pressed mientras su control está enfocado o pulsado (más abajo). Cada miembro que fija se aplica, en la web y en Photon, y cada miembro que deja sin fijar conserva el valor de la base. Hasta esta versión un hover en Photon aplicaba solo su fondo y su borde, y en la web no podía mover una caja ni cambiar sus propias sombras.
1
2
3
4
5
6
7
8
9
10
11
12
13
Box(new BoxStyle
{
Background = theme.Surface,
Elevation = 1,
Shadows = [new ShadowSpec(12, 30, -12, glow)],
Transition = new TransitionSpec(StyleChannels.Transform | StyleChannels.Shadow),
Hover = new StyleDiff
{
Transform = Transform2D.Translate(0, -2),
Elevation = 3,
Shadows = [new ShadowSpec(16, 36, -12, glow)],
},
}, content)
Esa tarjeta se levanta 2dp y profundiza su resplandor bajo el puntero, y se desliza en los dos sentidos. Lo que hace cada miembro mientras el estado está activo:
•
Background, BorderColor, BorderWidth, Opacity y BackdropBlur reemplazan los de la base.
•
Transform reemplaza el Transform de la base, igual que una regla de CSS reemplaza transform: una tarjeta rotada en reposo y levantada en hover declara los dos en su hover, Transform2D.Rotate(2).WithTranslate(0, -2), y Transform2D.Scale(1) deshace el de reposo.
•
Elevation y Shadows cambian su parte de UNA lista de sombras, y el resto de la lista se queda: la sombra de la elevación, luego las sombras personalizadas (las del estado reemplazan tanto el Shadow como el Shadows de la base), luego el resalte interior. Un estado que no deja nada que dibujar escribe una lista que no dibuja nada.
•
Gradient reemplaza el degradado de la base y conserva el resplandor y el patrón de rejilla de la base por debajo.
Una sombra sin desplazamiento, desenfoque ni expansión no dibuja nada, así que se deja fuera de la lista en los dos destinos. Bajo una Transition, los dos destinos deslizan la opacidad, el transform, los colores y cada sombra, las personalizadas incluidas, y una sombra que un estado añade o quita aparece o desaparece con un fundido (Photon desde la 0.2.0-preview.61). Una caja levantada se lleva además su área de impacto en la web, como hace cualquier transform de CSS, así que un puntero que descansa en el borde del que la caja se aparta puede levantarla y dejarla caer otra vez. Photon todavía hace hit-test de una caja transformada donde se maquetó, su transform de reposo incluido (#513). Mantén un levantamiento pequeño, o pon el hover en una caja que no se mueva.
StyleDiff es una clase desde esta versión (era un struct): Hover!.Value.Background pasa a ser Hover!.Background, y una caja que no declara ningún estado lleva un null en su lugar.
La pulsación y el foco de un control
Desde 0.2.0-preview.61
Un hover es el estado propio de la caja: el puntero está encima de ella. Una pulsación y un foco de teclado le ocurren a un CONTROL, al Pressable, así que Pressed y Focus se aplican a cada caja dentro del control mientras está pulsado o enfocado: la superficie, y cualquier cosa en ella que diga cómo se ve entonces.
1
2
3
4
5
6
7
8
Pressable(Box(new BoxStyle
{
Background = theme.Surface,
Elevation = 2,
Transition = new TransitionSpec(StyleChannels.Transform, Motion.BaseMs),
Pressed = new StyleDiff { Transform = Transform2D.Scale(0.985f) },
Focus = new StyleDiff { BorderColor = theme.FocusRing, BorderWidth = 2 },
}, label), onPressed: save)
Ese control se hunde al 98,5% y vuelve, y toma un borde de foco del teclado, en los dos destinos. Las reglas:
•
Cuando los estados se encuentran, una pulsación le gana a un foco y un foco le gana a un hover, miembro por miembro, como los ordena la §10 del handoff. Pressable.PressedBackground sigue siendo el relleno más fuerte.
•
El anillo de foco se dibuja al lado de las sombras de la caja: un control elevado o con resplandor conserva su elevación y su resplandor mientras tiene el foco. En la web el anillo las reemplazaba.
•
Una pulsación o un foco Simulated dibuja lo que dibuja uno real, el anillo incluido.
•
Un control sin PressedBackground conserva su propio relleno mientras está pulsado. En la web se volvía transparente.
•
Una transición que declara la superficie es la suya: el fundido del relleno pulsado es un valor por defecto que una Transition en la caja sobrescribe, así que una pulsación que escala se desliza. En la web el fundido la reemplazaba.
•
Un control deshabilitado no muestra pulsación ni foco, en los dos destinos: no cuando se deshabilitó mientras se mantenía pulsado, no en una imagen Simulated, y no cuando un control habilitado a su alrededor está pulsado.
Donde los destinos difieren: Photon no tiene tecla mantenida, una tecla activa su control al bajar, así que un diff Pressed se muestra para una tecla mantenida solo en la web. Y para un control dentro de un control, la web pulsa también el de fuera mientras el de dentro está pulsado, igual que el :active de CSS sube por los ancestros, mientras que Photon pulsa solo el control que tocó el puntero.
Focus cambió de significado en esta versión, del :focus-visible propio de la caja, que una caja nunca tiene, al foco de su control: un Focus escrito antes no se aplicaba en ningún sitio en la web ni en Photon, y ahora se aplica.
Una cabecera por debajo de la que el contenido se desplaza
Desde 0.2.0-preview.61
1
2
3
4
5
6
7
8
9
10
11
12
13
new Pinned(header)
{
Float = true,
ScrolledStyle = new StyleDiff
{
Background = theme.Surface,
Elevation = 2, // el scrolledUnderElevation de Flutter
BorderWidth = 1,
BorderColor = theme.Border, // la línea fina a lo largo del borde inferior de la cabecera
BackdropBlur = 24,
},
Transition = new TransitionSpec(StyleChannels.Colors | StyleChannels.Shadow),
}
Cada miembro de ScrolledStyle se aplica, en la web y en Photon, mientras la superficie a la que la cabecera se fija se ha desplazado más allá de Pinned.ScrolledThreshold (8dp). El diff se pone sobre Pinned.ScrolledBase: una cabecera no dibuja nada propio, y su borde es la línea fina a lo largo de su borde inferior, el borde por debajo del que el contenido se desplaza. Bajo la Transition de la cabecera, entra y sale deslizándose en los dos destinos.
La superficie es aquella a la que position: sticky se fija: el ScrollView más cercano alrededor de la cabecera, o la página cuando no está en ninguno. Una cabecera dentro de un panel que se desplaza sigue al panel, no a la ventana. En Photon la página misma nunca se desplaza, así que una cabecera fuera de todo ScrollView nunca está desplazada ahí.
Antes de esto la web escribía cuatro miembros de siete y un borde a solas, así que una Elevation, un Gradient, un Transform y los Shadows no hacían nada, y Photon no dibujaba nada de ello.
Qué está encima
Desde 0.2.0-preview.23
Photon pinta en ORDEN DE HIJOS: un hermano posterior está encima, y no hace falta decir nada. CSS deja de funcionar así en el momento en que las cajas adquieren contextos de apilado, así que el realizador web deja escrito lo que el nativo da gratis, en las palabras propias del design system, nunca en las de CSS.
Elevation decide qué está por encima, no solo cuán profunda es la sombra. Dibujaba una sombra y nada más, lo que es media frase: una superficie elevada que cualquier cosa pintada después tapa no está elevada. Los niveles 1 a 5 son el plano de CONTENIDO, y son los únicos números que escribe quien programa.
El chrome es un plano, no una tarjeta elevada. Cualquier cosa fijada (una cabecera Pinned, flotante o en el flujo) se sitúa por encima de toda elevación de contenido. Antes se situaba un paso por encima de nada, lo que es un número compitiendo con otros números: dale a la elevación el rango 1 a 5 y una tarjeta meramente elevada superaría a la cabecera en el apilado y se desplazaría por encima de ella.
Por eso Pinned no tiene Layer y nunca lo tendrá. Un z-index es vocabulario de CSS, y a quien programa y tiene que elegir uno se le está pidiendo saber qué números eligió todo lo demás. La elevación dice cuán alto, el realizador decide cómo se escribe eso, y el chrome no está en la misma escala que el contenido.
Una excepción a una declaración, una clase, y no es una excepción en absoluto una vez nombrada: un prefijo de proveedor no es una declaración propia, es la misma escrita para otro motor. El par comparte una clase cuya regla nombra las dos, la propiedad estándar al final. Sola en una regla, -webkit-backdrop-filter la descarta entera un motor que solo acepta el nombre estándar (insertRule deja una regla VACÍA en Chromium, medido), así que la clase se computaba, se hasheaba, se emitía, se ponía en el elemento y no hacía nada. Desde 0.2.0-preview.21.
Una clase atómica es el selector más débil que existe. Una clase es especificidad (0,1,0), así que cada regla de la hoja de estilos generada que nombre un elemento (.eq-spinner rect es (0,1,1)) supera a la clase que lleva un nodo. Eso hace que la restricción sea unidireccional: la hoja generada nunca debe declarar una propiedad que un nodo pueda llevar atómicamente, y un atajo cuenta, porque escribe cada propiedad larga que deja fuera.
El spinner es el caso medido. Sus ocho barras llevan cada una su propio animation-delay negativo, y esa fase ES la rotación; el fundido que estaba a su lado se escribió como animation: eq-spinner-fade 800ms linear infinite, que en silencio escribía animation-delay: 0s sobre las ocho. Renderizado en el servidor, el anillo pulsaba en el sitio en lugar de girar, mientras el cliente giraba correctamente por la razón equivocada, un estilo en línea superando a cada selector. Esa regla son propiedades largas ahora, y una guarda afirma que nada salvo Reduce Motion declara el retardo de una barra. La misma forma que el prefijo de proveedor de arriba: la clase se computaba, se hasheaba, se emitía, se ponía en el elemento, y ganaba otra cosa.
La tipografía de código
Desde 0.2.0-preview.7
Un fragmento monoespaciado (un Text con Mono, un fragmento enriquecido, un CodeBlock) se dibuja en una pila que el framework posee, y aterriza en una clase atómica, que le gana a una regla body { font-family }, y cuyo nombre es un hash de contenido que se mueve entre builds. Así que no había dónde colgar tu propia tipografía de código. El gancho es una variable:
1
:root { --eq-font-mono: 'JetBrains Mono', ui-monospace, monospace; }
Cada fragmento mono la sigue, con la pila de la plataforma como alternativa cuando nada la fija.
El medidor lee la misma variable, y eso no es un detalle de implementación que puedas ignorar si entregas un editor de código: CodeEditor coloca su caret a partir de un avance de columna medido, así que medir en una tipografía mientras se dibuja en otra pone el caret al lado del carácter en el que está en lugar de debajo.
El motor completo (leyes, pseudo-estados, clases de tamaño, la hoja de estilos generada) está documentado en DesignSystem. El vocabulario abstracto mismo (Box, Row/Column, Grid, Stack, …) está documentado en Componentes write-once.
Theming = proveer un IAppTheme
Los temas también son C# tipado (colores como pares claro/oscuro de ColorToken, roles tipográficos, escala de formas, elevación) seleccionados en una línea y puenteados de SSR al cliente:
1
2
3
4
5
builder.Services.AddUI(options =>
{
options.ScanAssembly(typeof(Program).Assembly)
.UseTheme(PhotonTheme.Instance); // o MaterialTheme.FromSeed(color)
});
Se entregan dos implementaciones: PhotonTheme (la de por defecto) y MaterialTheme (Material 3 de verdad, incluido el color dinámico a partir de una semilla). Cambia el tema y cada componente cambia de marca, en servidor y en cliente, en web y en nativo. El cambio claro/oscuro en tiempo de ejecución es un servicio (IThemeController); vea DesignSystem.
Un tema también responde con qué dibuja datos un gráfico: IAppTheme.Data es una DataPalette: ocho colores de serie en un orden fijo (identidad), una rampa secuencial de un solo matiz (magnitud), un par divergente sobre un punto medio neutro (polaridad), cuatro pasos de estado reservados (estado) y un gris de de-énfasis. Viene con una paleta de referencia validada por defecto, así que un tema que nunca pensó en gráficos sigue sirviendo uno correctamente; un tema con rampas de marca sobrescribe Data y somete el resultado a la misma auditoría, PaletteAudit (luminosidad y croma en OKLab, separación para visión del color, contraste), que es lo que hace que una paleta sea segura de cambiar. Una novena serie se rechaza por nombre, nunca se le da un matiz generado. El plan detrás de esto es docs/CHARTS-PLAN.md en el repositorio.
Y cuándo NO echar mano de un token
Un token INVIERTE con el modo, y es ese todo su propósito, y es por eso que casi todo debería ser uno. Así que la excepción es cualquier cosa cuyo significado no se invierte.
El caso que encontró esto: un brillo especular en una burbuja, pintado con TextInverse porque en modo claro eso es blanco y un brillo es blanco. En modo oscuro el mismo token es azul marino, así que la aplicación dibujaba un borrón oscuro exactamente donde va un reflejo. La luz es un blanco explícito, nunca un token: un reflejo es luz y no primer plano, y la luz no se vuelve oscura cuando la interfaz lo hace. El mismo razonamiento cubre una sombra que debe quedarse oscura, y un color de marca que una empresa no reestiliza al caer el sol.
Un borde en algunos lados
Desde 0.2.0-preview.13
1
2
3
4
5
6
7
8
9
10
11
12
13
new BoxStyle
{
BorderWidth = 1,
BorderColor = theme.Border,
BorderSides = BorderSides.Top, // una línea sobre una sección
}
new BoxStyle
{
BorderWidth = 3,
BorderColor = theme.Colors(Variant.Primary).Base,
BorderSides = BorderSides.Start, // una barra de acento a lo largo de una llamada
}
BorderSides.All por defecto, así que cada caja escrita antes de esto se renderiza byte por byte igual: un borde completo sigue emitiendo el atajo de una declaración y su única clase atómica.
Un borde está dentro de la caja, y el hijo también: la caja mantiene a su hijo dentro de su padding y de su borde en los lados en los que el borde se dibuja, y una caja que abraza a su hijo cuenta los dos en su tamaño, en los dos destinos (Photon ponía al hijo sobre el borde hasta la 0.2.0-preview.61).
El borde de un estado sigue los mismos lados: un Hover con BorderWidth = 2 en una caja que dibuja su lado inferior engrosa ese lado solo, en el color de la base cuando el estado no nombra ninguno. La web lo dibujaba en los cuatro lados, y no escribía nada para un ancho a solas (desde 0.2.0-preview.61).
Nada de esto es un Divider. Un Divider es un hermano entre dos cosas; estos pertenecen a la caja misma, y la única manera de dibujar uno era una Row envolviendo una Box de un dp, que es una mentira de layout sobre lo que el diseño quería decir.
Start y End en lugar de Left y Right, igual que EdgeInsets: se reflejan en una lectura de derecha a izquierda, y una barra de acento que se queda a la izquierda cuando el texto fluye al otro lado está en el lado equivocado de él.
VALLA Con un radio de esquina, un borde parcial difiere ligeramente entre destinos en la esquina donde un lado presente se encuentra con uno ausente: la web lo mitra, Photon lo escuadra. A radio 0 (una línea, una barra de acento, una celda de tabla, que es para lo que están los bordes parciales) los dos son idénticos. Usa BorderSides.All para un contorno redondeado.
2. La vía de escape a HTML
La capa web de bajo nivel (eQuantic.UI.Web) refleja el DOM 1:1. Cada HtmlElement expone los atributos que tiene el navegador:
1
2
3
4
5
6
7
public abstract class HtmlElement : IComponent {
/// <summary>Raw CSS classes (space-separated).</summary>
public string? ClassName { get; set; }
/// <summary>Inline styles for dynamic values (e.g., coordinates, colors from DB).</summary>
public HtmlStyle? Style { get; set; }
}
Este contrato significa que la capa web no impone ningún framework de CSS, renderiza atributos de HTML. DynamicElement va más allá y renderiza cualquier etiqueta con cualquier atributo. Las clases CSS corrientes de cualquier hoja de estilos funcionan de forma nativa:
1
new DynamicElement("aside") { ClassName = "my-sidebar" }
3. Traer CSS externo
El framework entrega exactamente un motor de estilos, la pipeline de C# tipado de arriba. No hay ningún framework de CSS incluido ni capa de adaptación: una página que necesita marcado escrito a mano usa la vía de escape (HtmlElement/DynamicElement + ClassName), y cualquier hoja de estilos que traiga la aplicación (su propio archivo CSS, un framework de utilidades que construya ella misma) se referencia como en cualquier proyecto web:
1
2
3
4
builder.Services.AddUI(options =>
{
options.ConfigureHtmlShell(shell => shell.AddStylesheet("/css/app.css"));
});
El framework no genera ni procesa ese CSS; es asunto del build de la propia aplicación.
4. Papeles de los paquetes
Paquete
Propósito
eQuantic.UI.Primitives
Estilos tipados, tokens de diseño, el vocabulario abstracto: la fuente de verdad de los estilos.
eQuantic.UI.Web
El realizador web + el motor de estilos atómico (StyleAtomizer) + la hoja de estilos generada, y la vía de escape espejo del DOM: HtmlElement, ClassName, HtmlStyle, DynamicElement.