eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsArchitecture
L'architecture des styles d'eQuantic.UI
Edit this page
18 min read
Philosophie centrale
eQuantic.UI offre une expérience de développement (DX) inspirée de Flutter tout en exploitant toute la puissance de chaque cible de rendu. L'architecture des styles repose sur trois piliers :
1.
Abstraction : les composants écrivent _quoi_ styler, sous forme de valeurs C# typées, jamais de chaînes CSS.
2.
Une sémantique, deux cibles : le même vocabulaire de styles conduit des valeurs dp en natif (Photon) et du CSS sur le web.
3.
Performance : génération et déduplication de classes au build, sans surcoût d'un runtime CSS-in-JS.
1. Styler des composants : du C# typé, aucun plan CSS
Le style des composants s'écrit entièrement en C# typé (BoxStyle, StyleDiff, tokens de design) et est abaissé par le moteur de styles atomique : chaque déclaration ordinaire devient UNE classe atomique dédupliquée, identique octet pour octet entre le SSR (C#) et l'hydratation (TS) ; les couleurs du thème référencent des custom properties var(--eq-color-*). Les états de survol et de focus, l'adaptativité par classe de taille de fenêtre, le positionnement collant et les transforms font tous partie du vocabulaire, sans le moindre JavaScript.
Le survol est dérivé, et il est réservé au pointeur
Depuis 0.2.0-preview.24
La palette de variantes reste à cinq sous-tokens ; le survol se résout par DÉRIVATION : VariantColors.Hover est le milieu par canal de Base → Pressed (ColorToken.MidpointWith, la même couleur sur laquelle tombe color-mix(in srgb, Base 50%, Pressed)), et les variantes discrètes survolent sur SurfaceSubtle. Les composants l'écrivent comme un diff BoxStyle.Hover ordinaire, les deux réalisateurs savaient donc déjà le peindre : le web comme une règle atomique :hover, Photon via la pipeline de pointeur.
Deux règles voyagent dans l'émission elle-même. Chaque règle atomique :hover est enveloppée dans @media (hover: hover), si bien que le survol émulé et collant d'un navigateur tactile ne trouve aucune règle, et la porte enveloppe la règle, jamais le hash, donc le SSR et l'hydratation comparent toujours des chaînes de classes identiques. Et sur Photon, le survol est suivi par CHEMIN de layout comme une CHAÎNE de tout ce qui se trouve sous le pointeur, parce que le :hover de CSS correspond à chaque ancêtre : survoler le déclencheur d'une infobulle survole aussi l'infobulle, et une reconstruction de composant, qui remplace chaque instance de nœud, ne perd plus le survol qu'elle a repeint.
Une couleur qu'un composant dérive tourne dans le navigateur
Depuis 0.2.0-preview.60
Le navigateur garde une Color comme donnée brute, { r, g, b, a }, parce que les styles du runtime, le design system généré et un payload d'hydratation en fabriquent tous une sans constructeur. Le code côté navigateur d'un composant l'écrit toujours comme du C# : Brand.WithOpacity(0.8f), a.MidpointWith(b), new Color(…), default(Color), $"{color}", et un groupe de méthodes tel que Func<float, Color> fade = Brand.WithOpacity. eqc abaisse chacun vers la Color du runtime, et le texte est le texte de record qu'écrit .NET, Color { R = 248, G = 113, B = 113, A = 204 }.
Avant la 0.2.0-preview.60 ceux-ci se rendaient sur le serveur et levaient dans le navigateur dès que la page s'hydratait (withOpacity is not a function), et un type propre à l'application nommé Color était construit comme celui du vocabulaire.
Une courbe qu'un composant fabrique tourne dans le navigateur
Depuis 0.2.0-preview.61
Une Curve est aussi une donnée brute dans le navigateur, { x1, y1, x2, y2 }, partout où le navigateur en rencontre une : un preset, la courbe d'un rôle de mouvement, l'easing d'une transition, une courbe qu'un composant fabrique, une courbe dans l'état d'une page. Le code côté navigateur d'un composant lit donc et fabrique une courbe comme le fait le C# : Curve.Standard.X1, Motion.Press.Curve, new Curve(0.2f, 0.9f, 0.3f, 1.25f) comme Easing d'un TransitionSpec, ==, with, une déconstruction, et $"{curve}", qui imprime Curve { X1 = 0.2, Y1 = 0, X2 = 0, Y2 = 1 } comme le fait .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 },
};
L'évaluateur reste sur le serveur et dans les shells natifs. Une transition web est une fonction de temporisation CSS, donc le navigateur évalue la courbe lui-même, et un composant qui appelle curve.Ease(t) ou CurveEvaluator.Ease(curve, t) fait échouer le build avec EQ2010. Remettez la courbe à un TransitionSpec, ou appelez l'évaluateur depuis du code serveur.
Avant la 0.2.0-preview.61 un preset était un tableau dans le navigateur et la courbe d'un rôle de mouvement un nom, donc un point lisait undefined, new Curve(…) levait "is not a constructor" dès que la page s'hydratait, et curve.Ease(t) compilait et levait.
Un état change n'importe quel membre du style
Depuis 0.2.0-preview.60
Un StyleDiff est l'aspect d'une boîte pendant qu'un état est actif : BoxStyle.Hover sous le pointeur, et BoxStyle.Focus et BoxStyle.Pressed pendant que son contrôle est focalisé ou pressé (plus bas). Chaque membre qu'il fixe s'applique, sur le web et sur Photon, et chaque membre qu'il laisse non fixé garde la valeur de la base. Jusqu'à cette version, un survol sur Photon n'appliquait que son fond et sa bordure, et sur le web il ne pouvait ni déplacer une boîte ni changer ses propres ombres.
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)
Cette carte se soulève de 2dp et approfondit sa lueur sous le pointeur, et glisse dans les deux sens. Ce que fait chaque membre pendant que l'état est actif :
•
Background, BorderColor, BorderWidth, Opacity et BackdropBlur remplacent ceux de la base.
•
Transform remplace le Transform de la base, comme une règle CSS remplace transform : une carte tournée au repos et soulevée au survol énonce les deux dans son survol, Transform2D.Rotate(2).WithTranslate(0, -2), et Transform2D.Scale(1) défait celui du repos.
•
Elevation et Shadows changent leur part d'UNE liste d'ombres, et le reste de la liste demeure : l'ombre de l'élévation, puis les ombres personnalisées (celles de l'état remplacent à la fois le Shadow et le Shadows de la base), puis le rehaut intérieur. Un état qui ne laisse rien à dessiner écrit une liste qui ne dessine rien.
•
Gradient remplace le dégradé de la base et garde la lueur et le motif de grille de la base en dessous.
Une ombre sans décalage, flou ni étalement ne dessine rien, elle est donc laissée hors de la liste sur les deux cibles. Sous une Transition, les deux cibles font glisser l'opacité, le transform, les couleurs et chaque ombre, les personnalisées comprises, et une ombre qu'un état ajoute ou retire apparaît ou disparaît en fondu (Photon depuis la 0.2.0-preview.61). Une boîte soulevée emporte aussi sa zone de clic sur le web, comme le fait tout transform CSS, donc un pointeur posé sur le bord dont la boîte s'éloigne peut la soulever et la laisser retomber. Photon teste encore le clic d'une boîte transformée là où elle a été mise en page, son transform de repos compris (#513). Gardez un soulèvement petit, ou mettez le survol sur une boîte qui ne bouge pas.
StyleDiff est une classe depuis cette version (c'était un struct) : Hover!.Value.Background devient Hover!.Background, et une boîte qui ne déclare aucun état porte un null à sa place.
La pression et le focus d'un contrôle
Depuis 0.2.0-preview.61
Un survol est l'état propre de la boîte : le pointeur est au-dessus d'elle. Une pression et un focus clavier arrivent à un CONTRÔLE, le Pressable, donc Pressed et Focus s'appliquent à chaque boîte dans le contrôle pendant qu'il est pressé ou focalisé : la surface, et tout ce qu'elle contient qui dit à quoi elle ressemble alors.
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)
Ce contrôle s'enfonce à 98,5% et revient, et prend une bordure de focus du clavier, sur les deux cibles. Les règles :
•
Quand les états se rencontrent, une pression bat un focus et un focus bat un survol, membre par membre, dans l'ordre que donne la §10 du handoff. Pressable.PressedBackground reste le remplissage le plus fort.
•
L'anneau de focus est dessiné à côté des ombres de la boîte : un contrôle surélevé ou avec une lueur garde son élévation et sa lueur pendant qu'il a le focus. Sur le web l'anneau les remplaçait.
•
Une pression ou un focus Simulated dessine ce qu'un vrai dessine, l'anneau compris.
•
Un contrôle sans PressedBackground garde son propre remplissage pendant qu'il est pressé. Sur le web il devenait transparent.
•
Une transition que la surface déclare est la sienne : le fondu du remplissage pressé est une valeur par défaut qu'une Transition sur la boîte surcharge, donc une pression qui met à l'échelle glisse. Sur le web le fondu la remplaçait.
•
Un contrôle désactivé ne montre ni pression ni focus, sur les deux cibles : ni quand il a été désactivé pendant qu'on le maintenait, ni dans une image Simulated, ni quand un contrôle activé autour de lui est pressé.
Là où les cibles diffèrent : Photon n'a pas de touche maintenue, une touche active son contrôle à la descente, donc un diff Pressed ne s'affiche pour une touche maintenue que sur le web. Et pour un contrôle à l'intérieur d'un contrôle, le web presse aussi celui de l'extérieur pendant que celui de l'intérieur est pressé, comme le :active de CSS remonte par les ancêtres, là où Photon ne presse que le contrôle que le pointeur a touché.
Focus a changé de sens dans cette version, du :focus-visible propre à la boîte, qu'une boîte n'a jamais, au focus de son contrôle : un Focus écrit auparavant ne s'appliquait nulle part sur le web ni nulle part sur Photon, et s'applique maintenant.
Un en-tête sous lequel le contenu défile
Depuis 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, // le scrolledUnderElevation de Flutter
BorderWidth = 1,
BorderColor = theme.Border, // le filet le long du bord inférieur de l'en-tête
BackdropBlur = 24,
},
Transition = new TransitionSpec(StyleChannels.Colors | StyleChannels.Shadow),
}
Chaque membre de ScrolledStyle s'applique, sur le web et sur Photon, pendant que la surface à laquelle l'en-tête s'épingle a défilé au-delà de Pinned.ScrolledThreshold (8dp). Le diff est posé par-dessus Pinned.ScrolledBase : un en-tête ne dessine rien de lui-même, et sa bordure est le filet le long de son bord inférieur, le bord sous lequel le contenu défile. Sous la Transition de l'en-tête, il entre et sort en glissant sur les deux cibles.
La surface est celle à laquelle position: sticky s'épingle : le ScrollView le plus proche autour de l'en-tête, ou la page quand il n'est dans aucun. Un en-tête dans un panneau qui défile suit le panneau, pas la fenêtre. Sur Photon la page elle-même ne défile jamais, donc un en-tête hors de tout ScrollView n'y est jamais défilé.
Avant cela le web écrivait quatre membres sur sept et une bordure seule, donc une Elevation, un Gradient, un Transform et les Shadows ne faisaient rien, et Photon n'en dessinait rien.
Ce qui est au-dessus
Depuis 0.2.0-preview.23
Photon peint dans l'ORDRE DES ENFANTS : un voisin plus tardif est au-dessus, et il n'y a rien à dire. CSS cesse de fonctionner ainsi dès que les boîtes acquièrent des contextes d'empilement, donc le réalisateur web explicite ce que le natif donne gratuitement, dans les mots propres du design system, jamais dans ceux de CSS.
Elevation décide de ce qui est au-dessus, pas seulement de la profondeur de l'ombre. Elle dessinait une ombre et rien d'autre, ce qui est une demi-phrase : une surface surélevée que recouvre tout ce qui est peint après elle n'est pas surélevée. Les niveaux 1 à 5 sont le plan du CONTENU, et ce sont les seuls nombres qu'un auteur écrit.
Le chrome est un plan, pas une carte surélevée. Tout ce qui est épinglé (un en-tête Pinned, flottant ou dans le flux) se place au-dessus de toute élévation de contenu. Il se plaçait auparavant un cran au-dessus de rien, ce qui est un nombre en concurrence avec d'autres nombres : donnez à l'élévation la plage 1 à 5 et une carte simplement surélevée dépasserait l'en-tête dans l'empilement et défilerait tout droit par-dessus.
C'est pourquoi Pinned n'a pas de Layer et n'en aura jamais. Un z-index est du vocabulaire CSS, et on demande à un auteur qui doit en choisir un de savoir quels nombres tout le reste a choisis. L'élévation dit à quelle hauteur, le réalisateur décide comment cela s'écrit, et le chrome n'est pas sur la même échelle que le contenu.
Une exception à une déclaration, une classe, et ce n'en est pas une du tout une fois nommée : un préfixe de fournisseur n'est pas une déclaration à lui, c'est la même écrite pour un autre moteur. La paire partage une classe dont la règle les nomme toutes deux, la propriété standard en dernier. Seul dans une règle, -webkit-backdrop-filter est jeté en entier par un moteur qui ne prend que le nom standard (insertRule laisse une règle VIDE dans Chromium, mesuré), donc la classe était calculée, hachée, émise, posée sur l'élément, et ne faisait rien. Depuis la 0.2.0-preview.21.
Une classe atomique est le sélecteur le plus faible qui existe. Une classe vaut une spécificité (0,1,0), donc chaque règle de la feuille de styles générée qui nomme un élément (.eq-spinner rect vaut (0,1,1)) dépasse la classe qu'un nœud porte. Cela rend la contrainte unidirectionnelle : la feuille générée ne doit jamais déclarer une propriété qu'un nœud peut porter atomiquement, et un raccourci compte, parce qu'il écrit chaque propriété longue qu'il omet.
Le spinner est le cas mesuré. Ses huit barres portent chacune leur propre animation-delay négatif, et cette phase EST la rotation ; le fondu à côté d'elles était écrit animation: eq-spinner-fade 800ms linear infinite, ce qui écrivait en silence animation-delay: 0s sur les huit. Rendu côté serveur, l'anneau pulsait sur place au lieu de tourner, tandis que le client tournait correctement pour la mauvaise raison, un style en ligne dépassant chaque sélecteur. Cette règle est en propriétés longues maintenant, et une garde affirme que rien sinon Reduce Motion ne déclare le retard d'une barre. La même forme que le préfixe de fournisseur ci-dessus : la classe était calculée, hachée, émise, posée sur l'élément, et autre chose gagnait.
La police du code
Depuis 0.2.0-preview.7
Un passage à chasse fixe (un Text avec Mono, un run riche, un CodeBlock) se dessine dans une pile que le framework possède, et il atterrit sur une classe atomique, qui bat une règle body { font-family }, et dont le nom est un hash de contenu qui bouge d'un build à l'autre. Il n'y avait donc nulle part où accrocher votre propre police de code. Le crochet est une variable :
1
:root { --eq-font-mono: 'JetBrains Mono', ui-monospace, monospace; }
Chaque passage mono la suit, avec la pile de la plateforme en repli quand rien ne la fixe.
Le mesureur lit la même variable, et ce n'est pas un détail d'implémentation que vous pouvez ignorer si vous livrez un éditeur de code : CodeEditor place son caret à partir d'une avance de colonne mesurée, donc mesurer dans une police tout en dessinant dans une autre met le caret à côté du caractère sur lequel il se trouve plutôt que dessous.
Le moteur complet (lois, pseudo-états, classes de taille, la feuille de styles générée) est documenté dans DesignSystem. Le vocabulaire abstrait lui-même (Box, Row/Column, Grid, Stack, …) est documenté dans Composants write-once.
Theming = fournir un IAppTheme
Les thèmes sont du C# typé aussi (des couleurs en paires clair/sombre de ColorToken, des rôles typographiques, une échelle de formes, l'élévation) sélectionnés en une ligne et pontés du SSR vers le client :
1
2
3
4
5
builder.Services.AddUI(options =>
{
options.ScanAssembly(typeof(Program).Assembly)
.UseTheme(PhotonTheme.Instance); // ou MaterialTheme.FromSeed(color)
});
Deux implémentations sont livrées : PhotonTheme (celle par défaut) et MaterialTheme (du vrai Material 3, couleur dynamique depuis une graine comprise). Changez le thème et chaque composant change de marque, serveur et client, web et natif. Le basculement clair/sombre à l'exécution est un service (IThemeController) ; voir DesignSystem.
Un thème répond aussi avec quoi un graphique dessine ses données : IAppTheme.Data est une DataPalette : huit couleurs de série dans un ordre fixe (identité), une rampe séquentielle d'une seule teinte (magnitude), une paire divergente sur un milieu neutre (polarité), quatre paliers d'état réservés (état) et un gris de mise en retrait. Elle a par défaut une palette de référence validée, donc un thème qui n'a jamais pensé aux graphiques en sert tout de même une correctement ; un thème avec des rampes de marque surcharge Data et soumet le résultat au même audit, PaletteAudit (luminosité et chroma en OKLab, séparation pour la vision des couleurs, contraste), et c'est ce qui rend une palette sûre à changer. Une neuvième série est refusée par son nom, jamais dotée d'une teinte générée. Le plan derrière cela est docs/CHARTS-PLAN.md dans le dépôt.
Et quand NE PAS attraper un token
Un token S'INVERSE avec le mode, et c'est tout son intérêt, et c'est pourquoi presque tout devrait en être un. L'exception est donc tout ce dont le sens ne s'inverse pas.
Le cas qui a trouvé ceci : un reflet spéculaire sur une bulle, peint avec TextInverse parce qu'en mode clair c'est du blanc et qu'un reflet est blanc. En mode sombre le même token est bleu marine, donc l'application dessinait une tache sombre exactement là où va un reflet. La lumière est un blanc explicite, jamais un token : un reflet est de la lumière et non du premier plan, et la lumière ne devient pas sombre quand l'interface le devient. Le même raisonnement couvre une ombre qui doit rester sombre, et une couleur de marque qu'une entreprise ne restyle pas au coucher du soleil.
Une bordure sur certains côtés
Depuis 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, // un filet au-dessus d'une section
}
new BoxStyle
{
BorderWidth = 3,
BorderColor = theme.Colors(Variant.Primary).Base,
BorderSides = BorderSides.Start, // une barre d'accent le long d'un encadré
}
BorderSides.All par défaut, donc chaque boîte écrite avant ceci se rend octet pour octet de la même façon : une bordure complète émet toujours le raccourci d'une déclaration et son unique classe atomique.
Une bordure est à l'intérieur de la boîte, et l'enfant aussi : la boîte garde son enfant dans son padding et sa bordure sur les côtés où la bordure est dessinée, et une boîte qui épouse son enfant compte les deux dans sa taille, sur les deux cibles (Photon posait l'enfant par-dessus la bordure jusqu'à la 0.2.0-preview.61).
La bordure d'un état suit les mêmes côtés : un Hover avec BorderWidth = 2 sur une boîte qui dessine son bord inférieur épaissit ce bord seul, dans la couleur de la base quand l'état n'en nomme aucune. Le web le dessinait sur les quatre bords, et n'écrivait rien pour une largeur seule (depuis la 0.2.0-preview.61).
Aucun de ceux-ci n'est un Divider. Un Divider est un voisin entre deux choses ; ceux-ci appartiennent à la boîte elle-même, et la seule façon d'en dessiner un était une Row enveloppant une Box d'un dp, ce qui est un mensonge de layout sur ce que le design voulait dire.
Start et End plutôt que Left et Right, comme EdgeInsets : ils se reflètent dans une lecture de droite à gauche, et une barre d'accent qui reste à gauche quand le texte coule dans l'autre sens est du mauvais côté de lui.
LIMITE Avec un rayon de coin, une bordure partielle diffère légèrement entre les cibles au coin où un côté présent rencontre un côté absent : le web le coupe en biais, Photon l'équarrit. À un rayon de 0 (un filet, une barre d'accent, une cellule de tableau, ce pour quoi les bordures partielles existent) les deux sont identiques. Utilisez BorderSides.All pour un contour arrondi.
2. L'échappatoire HTML
La couche web bas niveau (eQuantic.UI.Web) reflète le DOM à l'identique. Chaque HtmlElement expose les attributs que le navigateur possède :
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; }
}
Ce contrat signifie que la couche web n'impose aucun framework CSS, elle rend des attributs HTML. DynamicElement va plus loin et rend n'importe quelle balise avec n'importe quels attributs. Les classes CSS ordinaires de n'importe quelle feuille de styles fonctionnent nativement :
1
new DynamicElement("aside") { ClassName = "my-sidebar" }
3. Apporter du CSS externe
Le framework livre exactement un moteur de styles, la pipeline C# typée ci-dessus. Il n'y a aucun framework CSS fourni et aucune couche d'adaptation : une page qui a besoin de balisage écrit à la main utilise l'échappatoire (HtmlElement/DynamicElement + ClassName), et toute feuille de styles que l'application apporte (son propre fichier CSS, un framework d'utilitaires qu'elle construit elle-même) se référence comme sur n'importe quel projet web :
1
2
3
4
builder.Services.AddUI(options =>
{
options.ConfigureHtmlShell(shell => shell.AddStylesheet("/css/app.css"));
});
Le framework ne génère ni ne traite ce CSS ; c'est l'affaire du build de l'application.
4. Rôles des paquets
Paquet
Objet
eQuantic.UI.Primitives
Styles typés, tokens de design, le vocabulaire abstrait : la source de vérité des styles.
eQuantic.UI.Web
Le réalisateur web + le moteur de styles atomique (StyleAtomizer) + la feuille de styles générée, et l'échappatoire miroir du DOM : HtmlElement, ClassName, HtmlStyle, DynamicElement.