eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsArchitecture
Composants write-once (architecture partagée)
Edit this page
15 min read
La promesse centrale du récit multi-cible d'eQuantic.UI : les composants sont écrits une seule fois, en C#, contre un vocabulaire abstrait, et réalisés selon la cible : DOM + CSS sur le web, pixels GPU via Photon en natif. Pas "deux API similaires", littéralement la même classe.
Cette page est la vue d'ensemble de l'architecture ; le catalogue de composants vit sur la page Composants, et la façon d'ÉCRIRE un arbre (des factories plutôt que new, y compris pour vos propres composants) sur Surface déclarative.
Les couches
Paquet
Rôle
Dépend de
eQuantic.UI.Primitives
La base partagée : couleurs, tokens de design, styles typés, nœuds abstraits, modèle de composants
rien (zéro)
eQuantic.UI.Components
La bibliothèque de composants write-once
Primitives seulement
eQuantic.UI.Web
Réalisateur web : arbre abstrait → HtmlElement/DOM + CSS (SSR) + feuille de styles générée
Core, Primitives
eQuantic.UI.Native.Components
Réalisateur natif : arbre abstrait → display list Photon
Primitives, Engine
Runtime TS (@equantic/runtime)
Lowering côté client (hydratation) + modules de bibliothèque embarqués
rien
Le vocabulaire abstrait
Structure : Box (+BoxStyle : peinture, opacité, transform, aspect-ratio, diffs hover/focus), Row/Column (flex dont le gap est propriétaire + Wrap/RunGap), Grid (+GridTrack, le jumeau de CSS Grid), Stack/Positioned (ordre z + Layer), AdaptiveNode (classes de taille de fenêtre, zéro listener), ScrollView (+Pinned), Overlay (couche du viewport, drapeau Modal), Anchored (panneaux flottants : placements, scrim au tap extérieur, MatchAnchorWidth, OpenOnHover). Contenu : Text (rôles typographiques), TextEntry, Icon (glyphes curés + n'importe quel glyphe d'un pack), Image, Spinner, Markdown (un document rendu comme le design system), Mermaid (des diagrammes qu'il dessine lui-même). Interaction et mouvement : Pressable (contrat de zone tactile ≥48dp sous un doigt, 24dp sous un pointeur), Link (de vraies ancres sur le web, la couture de navigation de l'hôte en natif), DragDismiss (le geste de la feuille), LoopMotion, Presence (mouvement d'entrée/sortie). Plus les types valeur (EdgeInsets, SizeValue Hug/Fill/Fixed/WindowMinus, CornerRadii, ColorToken en paires clair/sombre, StyleDiff, Transform2D) et le modèle de composants (StatelessComponent, StatefulComponent + SetState, ComponentContext sans modes).
Les styles n'ont pas de plan CSS. Voir le moteur de styles atomique dans DesignSystem : chaque déclaration de style devient une classe atomique dédupliquée, identique octet pour octet entre le SSR (C#) et l'hydratation (TS).
La parité de layout est un contrat : le moteur flex en C# (natif) et le flex CSS (web) implémentent la même spécification : Flexible répartit le reste par poids, le contrat de troncature (le texte se réduit jusqu'aux points de suspension avant de pousser ses voisins), stretch ne remplit que l'automatique.
Depuis 0.2.0-preview.6
Panneaux responsives : Flexible avec une base
Flexible(child, flex, basis, shrink) est le triplet CSS flex: grow shrink basis. La base est la taille contre laquelle un parent qui enveloppe casse ses lignes, et c'est elle qui rend un layout responsive exprimable tout court :
1
2
3
4
5
// `Wrap` est une propriété init, donc voici la forme constructeur + initialiseur :
// les factories couvrent les constructeurs, jamais une seconde 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));
Assez large pour les deux bases, les panneaux partagent une ligne et se répartissent le reste par poids. Trop étroit, chacun prend une ligne à lui et grandit pour la remplir, au lieu de rester à 440 avec le reste de la ligne vide. Cette seconde partie est une vraie passe, pas un effet de bord du retour à la ligne : le reste va aux grandissants selon leur poids, une ligne qui déborde est reprise aux rétrécissants pondérés par la base (comme CSS la met à l'échelle), et rien ne descend sous le plancher du min-content.
Une base de 0, la valeur par défaut, est le comportement historique : l'enfant n'apporte rien de lui-même et est dimensionné purement par son poids. shrink: 0 refuse de rendre de la place, donc une ligne qui ne tient pas enveloppe plutôt que de se serrer.
Comment la même classe atteint les deux cibles
Natif : PhotonHost déplie Build() en ligne pendant le layout et le réalisateur émet des commandes de dessin.
Web, serveur (SSR) : le WebRealizer abaisse l'arbre en HtmlElements. Les couleurs deviennent light-dark(#l, #d) pour que le DOM reste sans mode (changer de thème = color-scheme). Les pages du Core peuvent embarquer des sous-arbres write-once via le pont VisualNodeComponent, et un StatefulComponent de Primitives portant [Page] est une page complète (le serveur fait le pont automatiquement).
Web, client : eqc (le compilateur C# vers JS) transpile les mêmes sources ; le lowering du runtime (lowerVisualNode) reflète le WebRealizer règle pour règle, et la parité d'hydratation est garantie par des chaînes de style identiques octet pour octet, épinglées de façon croisée et affirmées dans la suite C# comme dans celle en TS.
Une panne, un sous-arbre
Chaque cible déplie un composant par la MÊME couture, et cette couture est une frontière : si Build lève, le sous-arbre du composant en échec est remplacé par une surface contenue et tout ce qui l'entoure continue de rendre. Une page qui n'a pas de parent pour la contenir porte la même frontière sur sa propre couture de rendu, donc une page cassée est un panneau, jamais un document blanc.
avant
maintenant
Serveur (SSR)
500 pour toute la requête
200, panneau à la place du sous-arbre
Navigateur
le mount a levé, la racine jamais écrite
panneau, les voisins interactifs
Fenêtre (Photon)
la frame n'est jamais arrivée
panneau, l'application continue de présenter
Le panneau est construit avec le vocabulaire, c'est donc la même surface sur une page et dans une fenêtre. En développement il nomme le composant et cite l'exception ; en production il dit seulement qu'une section n'a pas pu être affichée, parce qu'un message d'exception est écrit pour qui a écrit le code, et peut nommer un id, un chemin ou une requête. Dans les deux cas la panne atteint le log de l'hôte via ComponentBoundary.Report : une frontière qui ne fait qu'avaler échange un crash bruyant contre un crash silencieux. Depuis la 0.2.0-preview.49 l'hôte Photon l'arme par défaut (votre logger si vous en avez un, stderr sinon), et une affectation faite avant le démarrage de l'application gagne (??=) : un report que vous aviez déjà câblé reste le vôtre, et une vérification qui cherche sa marque littérale continue de la trouver.
Rien n'est mémorisé. Un composant qui cesse de lever (une nouvelle tentative, de nouvelles props, un hot reload) se construit simplement de nouveau à la passe suivante.
Depuis 0.2.0-preview.55
Un composant dont le Build se renvoie lui-même, ou deux qui se renvoient l'un l'autre, est contenu de la même manière. Chaque réalisateur déplie ce que Build a renvoyé, donc un cycle rentrait dans Build jusqu'au débordement de pile, et .NET ne laisse personne attraper une StackOverflowException : la requête, la frame ou la fenêtre partaient avec elle. La couture compte désormais la profondeur d'imbrication des composants, et au-delà de 64 elle traite le parcours comme un parcours sans fin : le même panneau, la même entrée dans ComponentBoundary.Contained, le même report. Un arbre fini imbriqué plus profondément que cela est identique vu de l'intérieur, donc le panneau nomme ce qu'il a observé (un composant qui construit encore des composants après 64 dépliages) plutôt que de l'appeler un cycle.
Le CONTRAT propre d'un composant n'est pas une panne au moment du rendu et n'a pas sa place ici : énoncez-le là où l'erreur est écrite (init => field = value.Count > 3 ? throw … : value), pour que l'auteur reçoive une exception à la ligne qui s'est trompée plutôt qu'un panneau rouge une fois par frame.
La bibliothèque est fournie par le runtime
Les modules transpilés d'eQuantic.UI.Components sont livrés dans runtime.js, épinglés octet pour octet en CI contre la sortie vive d'eqc, et les applications les importent depuis @equantic/runtime. Les composants write-once écrits par l'utilisateur n'ont besoin d'aucun câblage : ils vivent dans l'application et passent par le scan de pages normal (eqc détecte les formes de composant de Primitives, y compris le stateful à SetState direct).
Généré, jamais écrit à la main
Chaque valeur du design system côté client est générée depuis la source C# unique et épinglée octet pour octet :
•
PhotonCssGenerator → la feuille de styles normative (custom properties, classes de rôle typographique, élévation, mouvement).
•
DesignSystemTsGenerator → design-system.generated.ts (tokens, thème, table de tailles de Button).
•
IconTsGenerator → icons.generated.ts (données de path des glyphes depuis l'IconRegistry en C#).
La bibliothèque : une bibliothèque, écrite une fois
Il existe exactement UNE bibliothèque de composants. Boutons, cartes, champs, navigation, overlays, listes, la ListView à recyclage, la grille Spreadsheet, l'éditeur de code. Le catalogue complet, groupé et décrit, est sur la page Composants.
Chaque composant est livré avec des pins du réalisateur web, des goldens natifs (clair+sombre), des fixtures transpilées et épinglées exécutées dans vitest, et le showroom vivant (/ et /shared dans DefaultUIDashboard : SSR + identité d'hydratation + interaction vérifiées de bout en bout par la suite Playwright). Les systèmes livrés aux côtés de la bibliothèque : le système de mouvement par transition d'état (entrée/sortie de Presence), la pipeline de pointeur (hover, glisser-pour-fermer avec slop/cancel/glide), le compositeur de scroll (vrai épinglage, via Pinned) et les overlays ancrés.
Un lien à l'intérieur d'une phrase
Depuis 0.2.0-preview.13
TextRun.Destination, parce qu'un lien au milieu d'un paragraphe n'a nulle part ailleurs où vivre :
1
2
3
4
5
6
7
8
9
new Text("", TypeRole.BodyM)
{
Spans =
[
new TextRun("Read the "),
new TextRun("getting started") { Destination = "/docs/start", Weight = FontWeight.SemiBold },
new TextRun(" guide."),
],
}
Un Link autour du Text entier fait du paragraphe entier un seul lien. Une Row de Texts casse entre les runs et non entre les mots, donc une phrase avec trois fragments de code enveloppe aux fragments, et c'est précisément pourquoi l'emphase mixte doit être un seul Text avec des Spans dès le départ. Entre ces deux, un lien en milieu de phrase était inexprimable, et pour de la prose cela fait la plupart des paragraphes.
Un run peut aussi avoir une TAILLE différente de la prose qui l'entoure : StyleOverride sur le run, la même échappatoire qu'est Text.StyleOverride. Du code en ligne à 13.5 dans un paragraphe à 16 est le cas, et le run garde la boîte de ligne du paragraphe : fixer sa propre line-height ouvrirait un vide au-dessus et en dessous, ce qui est une ligne, pas un run. Depuis 0.2.0-preview.15.
Un Text n'est jamais en ligne de lui-même : sur le web c'est un bloc où qu'il se trouve, avec sa propre boîte de ligne à la hauteur que dicte son style typographique (depuis 0.2.0-preview.61). En span en ligne dans un Box avec du padding, il se posait sur la ligne de la boîte, dont le strut est la police du corps de la page, et un label 10/15 dans une pilule sortait à 26.5px de haut là où ses parties totalisent 21. En bloc il prend la largeur que son parent lui donne, comme le fait le Text de Flutter sous une largeur contrainte, donc son alignement se lit sur cette largeur. Une phrase est un Text avec des Spans : plusieurs Texts dans un HtmlElement prennent une ligne chacun.
La FORME est universelle : un NSAttributedString d'Apple porte un attribut .link sur une plage, un Spannable d'Android porte un URLSpan. Un lien en ligne est un run avec un attribut partout, et ne peut pas être un nœud séparé, parce qu'un nœud séparé est justement ce qui casse la ligne.
Le NOM découle de la même règle. Apple dit link, Android dit URLSpan, HTML dit href, donc href est le mot d'une seule cible, et la couche abstraite ne parle la langue d'aucune cible (Pressable, pas "Button"). C'est Destination sur Link comme sur TextRun : ce que la valeur EST, c'est-à-dire une route ou une URL, dans un mot qu'aucune des trois cibles ne possède.
RENOMMÉ EN 0.2.0-PREVIEW.13, depuis Href, dans la version même où il est apparu. Un changement cassant pris tant qu'il représente une ligne par application plutôt qu'après qu'il y ait des applications à casser.
Spans est réalisé sur le web aujourd'hui. Photon réalise Link (il collecte les régions de lien et les teste au clic, remettant la destination à la couture de navigation du shell) mais pas la destination par RUN, donc un paragraphe avec des liens en ligne s'y dessine comme son texte, sans lien. Ce qui manque, c'est le hit-test par run, pas le sens.
La route sur laquelle se trouve une page
Depuis 0.2.0-preview.13
Une page sur /docs/{slug} lit le slug depuis son propre contexte :
1
2
3
4
5
6
7
8
9
10
11
12
13
[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") dans Build, RouteValues.Current.Param("slug") dans un prefetch (qui tourne avant qu'il y ait un contexte).
•
context.Route.Query("page") pour la query string.
•
Jamais null ; un nom sans correspondance répond null.
Avant cela, le seul chemin était IHttpContextAccessor. Cela marche, et cela coûte à la page exactement ce qui la rend write-once : un composant qui connaît ASP.NET est un composant qui ne peut pas tourner sur Photon. Lire un paramètre de route est la chose la plus ordinaire que fait une page, et si la façon de le faire est spécifique au web, alors "write-once" tient pour le visuel et pas pour la page.
La route est armée avant le prefetch, et c'est la partie qui compte : une page sur /docs/{slug} charge par le slug, donc une route qui arrive après le prefetch arrive après la seule question à laquelle elle était là pour répondre.
Par requête, sur un AsyncLocal comme toute autre valeur par requête, parce que le SSR rend des requêtes concurrentes sur des instances partagées, et un slug gardé dans un champ remettrait le document d'un visiteur à un autre.
NÉCESSITE 0.2.0-PREVIEW.14 POUR ÊTRE LU DEPUISBuild. En preview.13 le runtime n'exportait pas RouteValues, donc une page le nommant hors [ServerOnly] mourait à l'hydratation sur "does not provide an export named 'RouteValues'", tandis que le SSR continuait de répondre 200 avec un balisage correct, et c'est précisément pour cela que rien ne semblait cassé. Le lire depuis un prefetch [ServerOnly] marche sur les deux.
Entrer dans l'arbre, et en sortir
Depuis 0.2.0-preview.13
1
2
3
4
5
6
7
8
9
10
11
12
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 constructeur ne peut pas être l'endroit où charger un état stocké, s'abonner à un périphérique ou lancer une requête : le réconciliateur construit des instances neuves à chaque passe et garde celle qui est retenue, donc tout ce qu'un constructeur démarre est démarré de nouveau pour une instance qui est ensuite jetée. OnMount tourne une fois, sur l'instance qui reste.
OnUnmount est livré avec lui plutôt qu'après lui, parce que sans le second chaque mount est une fuite.
Trois choses à savoir :
•
Livré quand la passe se ferme, pas au milieu du build. Un hook qui tournerait dans le réconcile tournerait pendant que son propre parent est encore en construction, donc un SetState depuis là muterait un arbre à moitié produit.
•
Le sortant se démonte avant que l'entrant ne se monte : l'ordre qui permet à deux composants de partager une ressource exclusive à travers un échange.
•
Cela ne veut pas dire "les pixels existent". Photon n'a pas de DOM dont lire la géométrie, donc un hook qui le promettrait ne pourrait pas être write-once. Cela veut dire : ce composant est dans l'arbre, son build est sur le point d'être montré, et un SetState d'ici planifie la passe suivante au lieu de se battre avec l'actuelle.
Une RACINE de page n'a pas de parent pour la monter, donc la surface s'en charge : PhotonHost après sa première frame, la base web au mount/hydrate.
Tester un arbre sans serveur
Un composant est une valeur, donc affirmer ce qu'il rend coûte un assert, pas un processus. Le réalisateur web est public et les tests du framework lui-même utilisent exactement ceci :
1
2
3
4
5
6
7
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");
Trois étapes, parce que chacune fait une chose : Lower(node, theme) réalise l'arbre, .Render() transforme l'élément réalisé en HtmlNode, et HtmlRenderer.RenderNode(...) écrit ce nœud comme du balisage. S'arrêter à .Render() vous donne le nœud, ce qui est utile quand l'assertion porte sur la structure (.Tag, .Attributes) plutôt que sur le texte, ce que veulent la plupart des tests du framework lui-même. Sans puits de styles, les styles restent en ligne, et c'est ce que vous voulez dans un test : la forme en classe atomique est pour la page, et affirmer sur un hash de contenu c'est affirmer sur un artefact de build.
Deux choses que cela apporte au-delà de la vitesse. Un test peut rendre le MÊME nœud pour les deux réalisateurs et comparer ce que chacun en a fait, et c'est ainsi que le contrat de parité de layout est tenu. Et un composant qui lève est attrapé par la frontière, donc un sous-arbre cassé fait échouer son propre assert au lieu d'emporter la suite avec lui.