Repository navigation
Styling es
🌐 Esta página en: English · Português · Español · Français · Deutsch
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:
- Abstracción: los componentes escriben qué estilizar, como valores tipados de C#, nunca cadenas de CSS.
- Una semántica, dos destinos: el mismo vocabulario de estilos conduce valores dp en nativo (Photon) y CSS en la web.
- Rendimiento: generación y deduplicación de clases en tiempo de build, sin sobrecoste de un runtime de CSS-in-JS.
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.
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ó.
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.
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.
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.
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.
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,OpacityyBackdropBlurreemplazan los de la base. -
Transformreemplaza elTransformde la base, igual que una regla de CSS reemplazatransform: una tarjeta rotada en reposo y levantada en hover declara los dos en su hover,Transform2D.Rotate(2).WithTranslate(0, -2), yTransform2D.Scale(1)deshace el de reposo. -
ElevationyShadowscambian 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 elShadowcomo elShadowsde la base), luego el resalte interior. Un estado que no deja nada que dibujar escribe una lista que no dibuja nada. -
Gradientreemplaza 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.
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.
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.PressedBackgroundsigue 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
Simulateddibuja lo que dibuja uno real, el anillo incluido. - Un control sin
PressedBackgroundconserva 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
Transitionen 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.
Desde 0.2.0-preview.61
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.
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.
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:
: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.
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:
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.
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.
Desde 0.2.0-preview.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.Allpara un contorno redondeado.
La capa web de bajo nivel (eQuantic.UI.Web) refleja el DOM 1:1. Cada HtmlElement expone los
atributos que tiene el navegador:
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:
new DynamicElement("aside") { ClassName = "my-sidebar" }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:
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.
| 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. |
🏁 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