eQuantic.UI - ArquiteturaO eQuantic.UI é um framework de componentes para .NET em que um componente é escrito uma vez, em C#, e realizado em dois tipos de alvo: a web, como DOM e CSS servidos pelo ASP.NET Core e conduzidos no browser por JavaScript que o build compilou a partir desse mesmo C#; e janelas e telefones nativos, como pixels pintados pelo motor GPU Photon. Não há uma segunda linguagem a aprender nem toolchain externa a instalar: o compilador, o bundler e o motor viajam dentro dos próprios pacotes do SDK. Componentes C#, um vocabulário
O diagrama acima é um fence mermaid `: o GitHub desenha-o aqui, e o site da documentação desenha o MESMO fence através do componente Mermaid do próprio SDK. 1.
✅ 100% .NET: sem Node.js, npm ou Vite na máquina do developer; a toolchain vem embarcada.
2.
✅ Escrever uma vez: uma classe de componente, realizada no DOM e numa display list de GPU.
3.
✅ Autocontido: o ASP.NET Core serve, renderiza e compila tudo para a web.
4.
✅ Familiar: rotas são atributos, serviços vêm do IServiceCollection, configuração do appsettings.json; quem faz .NET já conhece os idiomas.
5.
✅ Nenhum artefacto de plataforma: sem Swift, sem manifest, sem plist, sem CSS. O SDK deriva o que cada alvo precisa a partir de declarações em C#.
Camada
Pacote
O que contém
Pode referenciar
O vocabulário
eQuantic.UI.Primitives
os nós visuais abstratos (Box, Row, Text, Pressable, …), estilos tipados e tokens de design, o modelo de componente, os atributos de contrato
nada
A biblioteca
eQuantic.UI.Components, eQuantic.UI.Charts
os componentes write-once (Button, Card, DataTable, BarChart, …), compostos só a partir do vocabulário
Primitives
O realizador web
eQuantic.UI.Web
baixa uma árvore de nós para HtmlElement/DOM e CSS atómico no servidor; também a saída de escape do DOM (HtmlElement, DynamicElement, ClassBuilder)
Primitives
O host web
eQuantic.UI.Server
integração ASP.NET Core: SSR, Server Actions, rotas, metadados, assets, hot reload
Web
O runtime do browser
@equantic/runtime, empacotado pelo eQuantic.UI.Server a partir do TypeScript do eQuantic.UI.Runtime
o gémeo TypeScript do realizador web, o reconciliador, estado, o router, e os módulos transpilados da própria biblioteca
nada
A trilha nativa
eQuantic.UI.Native.Engine (+ Metal, Vulkan, Reference), Native.Framework, Native.Components, Native.Hosting, os shells
o motor GPU e os seus backends, o motor de layout, o realizador e o host Photon, o application builder, e um shell por plataforma
Primitives, e uns aos outros para baixo
O compilador
eQuantic.UI.Compiler (eqc)
o transpilador C# → TypeScript baseado em Roslyn que o SDK web executa
Codegen
Os SDKs
eQuantic.UI.Sdk, eQuantic.UI.Sdk.Native
SDKs MSBuild: um atributo Sdk="…" traz todos os pacotes acima e liga o build
nada
Duas regras seguram a tabela. O vocabulário não referencia nada, por isso pode ir para todos os alvos, incluindo o bundle do browser e o Native AOT. Um realizador nunca referencia a biblioteca que realiza: um componente chega a um realizador como a árvore que o seu Build produziu, portanto Web, Email e Native.Components conhecem o vocabulário e não Components. As duas regras são testes no repositório (AssemblyLayeringTests), não convenções.
O ficheiro de projeto de uma app web nomeia o SDK e mais nada, sem nenhum PackageReference:
<Project Sdk="eQuantic.UI.Sdk">
<TargetFramework>net10.0</TargetFramework>
A versão vem do global.json, como em qualquer SDK MSBuild:
"eQuantic.UI.Sdk": "0.2.0-preview.52"
O Sdk.props importa o Microsoft.NET.Sdk.Web, acrescenta os pacotes do framework (Primitives, Code, Components, Charts, Web, Server, o source generator e o pacote do Bun embarcado para o SO e a arquitetura da máquina que constrói), põe a superfície declarativa em scope em todos os ficheiros (eQuantic.UI.Components.UI e eQuantic.UI.Charts.ChartsUI como usings estáticos), e inclui Pages/**/*.cs como os componentes que o build compila para o browser. Ao lado de um checkout do código-fonte do framework troca cada pacote pelo projeto correspondente, para os samples construírem contra a árvore. Veja Fluxo de build. Reduzido às partes que interessam, esse ficheiro lê-se assim:
<!-- eQuantic.UI.Sdk/Sdk/Sdk.props -->
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />
<EnableEQuanticUICompilation>true</EnableEQuanticUICompilation>
<EQuanticOutputPath>wwwroot/_equantic/</EQuanticOutputPath>
<!-- a superfície declarativa, em scope em todos os ficheiros -->
<Using Include="eQuantic.UI.Components.UI" Static="true" />
<Using Include="eQuantic.UI.Charts.ChartsUI" Static="true" />
<!-- o que o build compila para o browser -->
<UIComponent Include="Pages/**/*.cs" Exclude="obj/**;bin/**" />
2.2 O que o dotnet build faz
Microsoft.NET.Sdk.Web, o pipeline normal, até o CoreCompile (o C# do projeto)
ResolveBunPath o Bun embarcado para esta máquina, extraído uma vez do seu pacote
CompileEQuanticUI depois do CoreCompile, e antes de os static web assets serem definidos
1. o eqc lê as fontes do projeto e as fontes distribuídas da biblioteca
2. o Roslyn liga-as. O modelo semântico é autoritativo; uma chamada que não liga é erro de build
3. um módulo TypeScript por página ou componente
4. o Bun empacota cada módulo; em Debug, com um source map de volta ao C# (EQuanticSourceMaps)
CopyEQuanticRuntime o runtime.js servido, do pacote Server
Saída: wwwroot/_equantic/, definida como static web assets a partir do que está no disco, para
que a compressão, os manifestos e o publish vejam os arquivos deste build
O intermediário TypeScript é deliberado: o código emitido é legível, tipado uma segunda vez pelo compilador TypeScript, e mapeado de volta à linha C# para o depurador do browser. O bundler é o Bun, porque é um executável único que empacota TypeScript sem configuração, e viaja como um pacote por sistema operativo e arquitetura (eQuantic.UI.Runtime.OsxArm64, .Win64, .Linux64, …) escolhido pelo SDK. Ninguém o instala.
Isto é o que o eqc emite para um dos componentes da própria biblioteca, o Divider, exatamente como viaja dentro do runtime.js. O C# de onde veio é o Divider.cs em eQuantic.UI.Components: a mesma classe, o mesmo Build, os enums baixados para os seus nomes em camelCase, o object initializer a chegar como o props final:
import { Box, BoxStyle, BuildContext, EdgeInsets, SizeValue, StatelessComponent } from "../runtime-exports";
export class Divider extends StatelessComponent {
declare leadingInset: any;
constructor(inset: any = 'none', axis: any = 'horizontal', props?: any) {
if (inset !== undefined) this.inset = inset;
if (axis !== undefined) this.axis = axis;
if (this.inset === undefined) this.inset = 'none';
if (this.axis === undefined) this.axis = 'horizontal';
if (props && typeof props === 'object') Object.assign(this, props);
build(context: BuildContext) {
let line = new Box(new BoxStyle({ width: this.axis === 'vertical' ? SizeValue.fixed(1) : SizeValue.fill, height: this.axis === 'vertical' ? SizeValue.fill : SizeValue.fixed(1), background: context.theme.border }));
if (this.inset === 'none') return line;
let padding = this.inset === 'leading' ? new EdgeInsets(this.leadingInset ?? 16, 0, 0, 0) : EdgeInsets.symmetric(16, 0);
return new Box(new BoxStyle({ width: SizeValue.fill, padding: padding }), line);
2.3 O que um build escreve
Os tamanhos não são citados aqui, porque mudam a cada alteração: o tamanho gzip do runtime servido é um orçamento que a suíte de testes impõe (ServedRuntimeBudgetTests, contra o registro em tests/eQuantic.UI.Server.Tests/Budgets/served-runtime.json), e o CI reporta o tamanho de cada módulo de página em todo pull request.
runtime.js
o runtime do browser: o reconciliador, eventos, estado, a ponte de Server Actions, o router, E a biblioteca de componentes partilhada, cujos módulos transpilados viajam dentro dele. A página chega a ele como o módulo @equantic/runtime através do import map do shell, e o Server responde essa URL com o bundle que embarca; este arquivo tem os mesmos bytes, para as ferramentas que carregam os módulos fora de um servidor (o preview do VS Code o lê)
<Componente>.js, e .js.map em Debug
um módulo por página ou componente, plano. O boot importa o módulo da página na navegação, portanto o carregamento preguiçoso por rota sai do grafo de módulos; não há pasta pages/. O eqc roda o bun com --splitting, então o que dois ou mais módulos compartilham é um chunk com o nome de um deles e um hash do conteúdo: NotFoundScreen-<hash>.js no sample do dashboard é o shell do console dele, não um segundo tipo com esse nome. O mapa carrega o C# de onde o módulo veio, por isso o EQuanticSourceMaps é full em Debug e none em todo o resto (external escreve um sem o C#, para um serviço de erros), e um publish nunca leva um
strings/<cultura>.json
os catálogos de cultura, quando a app tem .resx
icons/
os tamanhos do ícone da app e o manifesto web, quando um AppIcon é declarado
Os estilos não são um arquivo: o servidor os escreve na própria página, entre eles a folha de tokens gerada e as classes atômicas (<style id="eq-atomic">).
2.4 Renderização no servidor e hidratação
O servidor renderiza o primeiro HTML a partir do MESMO C#: o WebRealizer baixa a árvore do componente para HtmlElements, e as classes de estilo atómicas que escreve têm o mesmo hash das que o lowering.ts do browser escreve, portanto a hidratação compara nomes de classe e não repinta nada. Uma página que implementa IServerPrefetch tem os dados carregados no servidor ANTES do primeiro build, e a resposta viaja para o browser com a página: a primeira pintura já tem dados, e não há estado de carregamento para desenhar. Veja Componentes write-once para o contrato de paridade e Integração com o servidor para as opções do pipeline. A mesma classe de componente, construída pelo dotnet build numa aplicação .NET comum (Native AOT no publish), hospedada pelo Photon: PhotonApplication.CreateBuilder(args) … .Build().UseRoot<Shell>()
LayoutEngine as constraints descem, os tamanhos sobem, os pais posicionam, tudo numa passagem
PhotonRealizer a árvore disposta vira uma display list: caixas arredondadas, sombras, texto, imagens, ícones
RHI um renderer sobre IRhiDevice; Metal na Apple, Vulkan no Windows e no Android, um backend Reference em CPU em todo o lado
Shell macOS, iOS, Android ou Windows: a janela, o input, os serviços de texto, a ponte de acessibilidade, as capacidades
O texto é moldado pela plataforma (CoreText, DirectWrite, o layout de texto do Android) atrás de um só contrato ITextMeasurer; as capacidades (câmara, localização, armazenamento, biometria, …) são interfaces tipadas que o shell implementa e que um componente recebe pelo construtor ou por GetService<T>(). O hot reload é o dotnet watch: o runtime aplica a edição no processo e a janela redesenha.
Uma página que precisa de dados não devia precisar de um controller, um DTO, um fetch e um contrato JSON mantidos à mão em sintonia. Precisa de um método.
Sem isso, a mesma funcionalidade espalha-se por quatro sítios que só concordam por disciplina:
public class TodoController : ControllerBase
public Task<Todo> AddTodo([FromBody] AddTodoRequest request) { /* … */ }
async function addTodo(title) {
const response = await fetch("/api/todos", { method: "POST", body: JSON.stringify({ title }) });
return await response.json();
public class TodoList : StatefulComponent
private List<Todo> _todos = [];
// Corre no SERVIDOR. O cliente recebe uma chamada, não o corpo.
public async Task<List<Todo>> LoadTodos()
using var db = new AppDbContext();
return await db.Todos.ToListAsync();
public async Task<Todo> AddTodo(string title)
using var db = new AppDbContext();
var todo = new Todo { Title = title };
await db.SaveChangesAsync();
protected override async void OnMount() => await Load();
private async Task Load()
var loaded = await LoadTodos();
SetState(() => _todos = loaded);
private async Task Add(string title)
var added = await AddTodo(title); // tipado, serializado por si
SetState(() => _todos.Add(added));
public override VisualNode Build(ComponentContext context) =>
Column(gap: Space.S2, children: [.. _todos.Select(todo => TodoItem(todo))]);
O compilador substitui o corpo de cada [ServerAction] por uma chamada à ponte:
return await this._serverActions.invoke("TodoList/LoadTodos", []);
O app.UseServerActions() responde em POST /api/_equantic/actions. Só métodos marcados com [ServerAction] são chamáveis (uma whitelist, não uma convenção), e [Authorize] é aplicado antes de o método correr. O tamanho do payload e os tipos dos argumentos são validados; veja Segurança. Um Server Action é pedido/resposta; o que o servidor publica enquanto uma página está aberta chega a ela pelos eventos do servidor. No fio é um POST por chamada, o que vale saber quando se lê a aba de rede:
{ "actionId": "TodoList/LoadTodos", "arguments": [] }
{ "success": true, "result": [ { "id": 1, "title": "Comprar leite" } ] }
Um componente que precisa de um script ou de uma folha de estilo externa declara-o, e o servidor injeta as tags uma vez por página, sem duplicados:
public class CodeBlock : StatelessComponent, IRequireAssets
public void ConfigureAssets(AssetBuilder assets)
assets.AddStylesheet("https://cdn.example.com/prism.css", id: "prism-theme");
assets.AddScript("https://cdn.example.com/prism.js");
IComponentAssetProvider<T> faz o mesmo para um componente que não escreveu, registado no DI ou encontrado pela varredura de assemblies do AddUI():
public class PrismAssetProvider : IComponentAssetProvider<CodeBlock>
public void ConfigureAssets(AssetBuilder assets) =>
assets.AddScript("https://cdn.example.com/prism.js");
// registo explícito, quando não deve ser a varredura a encontrá-lo
options.WithAssetProvider<PrismAssetProvider>();
O fluxo: o ServerRenderingService percorre a árvore renderizada a recolher os dois tipos, o AssetCollection deduplica por chave (folhas de estilo primeiro, depois scripts), e as tags aterram no <head>. Uma página sem componentes que precisem de assets não recebe nenhuma. Veja Assets. Uma rota é um atributo na página, descoberto em tempo de build, então não há tabela de rotas a manter em sintonia com as páginas:
[Page("/count")] // várias rotas, uma página
public class Counter : StatefulComponent { }
[Page("/user/{id:int}")] // sintaxe de rota do ASP.NET, parâmetros incluídos
public class UserProfile : StatefulComponent
public override VisualNode Build(ComponentContext context)
var id = context.Route.Param("id"); // o que a rota disse, sem alvo nenhum
var page = context.Route.Query("page"); // e a query string
[Page("/admin/dashboard")]
[Authorize(Roles = "Admin")]
public class AdminDashboard : StatefulComponent { }
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddUI(options => options.ScanAssembly(typeof(Program).Assembly));
var app = builder.Build();
app.MapUI(); // todos os [Page], mais o fallback SPA
No browser, o router do runtime trata a navegação sem recarregar: um Link é uma âncora real, e Navigator.Go(destination) é o gémeo programático para as rotas que um componente decide tomar sozinho. No Photon, o mesmo Link e o mesmo Navigator chegam à costura de navegação do host.
6. Experiência de desenvolvimento
├── MyApp.csproj # <Project Sdk="eQuantic.UI.Sdk">
├── global.json # a versão do SDK
├── Program.cs # o host ASP.NET Core
├── Pages/ # componentes [Page]
├── Components/ # os seus próprios componentes write-once
├── Assets/ # AppIcon.cs, *.svg → vetores em tempo de build
├── Resources/ # .resx → strings/<cultura>.json
└── _equantic/ # gerado pelo build; os arquivos .map em Debug
├── Counter.js Counter.js.map
dotnet new install eQuantic.UI.Templates # uma vez
dotnet new equantic-app -n MyApp # uma app web
dotnet new equantic-native -n MyApp # uma app Photon
dotnet watch run # hot reload, web ou nativo
dotnet build # C# → TypeScript → JavaScript, pronto para SSR
dotnet publish -c Release # uma app publicável; Native AOT no Photon
1. Você edita o Counter.cs
2. o servidor, que observa os .cs do app, roda o eqc de novo e o Counter.js é reescrito no lugar;
enquanto isso, o dotnet watch aplica a mesma edição ao servidor em execução
3. o servidor empurra um evento em /_equantic/hmr, server-sent events com heartbeat e reconexão
4. o browser busca o módulo novo e MONTA a página de novo, com os campos que a página tinha;
a marcação antiga do servidor não é re-hidratada
5. no Photon não há passo 3: o runtime aplica a edição dentro do processo em execução,
e o host redesenha no frame seguinte
Veja Depuração para o overlay de erros e os source maps que apontam de volta à linha C#. 7. Comparado com o WebForms
Aspeto
WebForms
eQuantic.UI
Estado
ViewState num campo escondido
estado na instância do componente, mantido pelo reconciliador; o servidor guarda dados, não estado de vista
Renderização
HTML gerado no servidor a cada postback
primeira pintura renderizada no servidor, depois um reconciliador no cliente
Atualizações
postback da página inteira
no lugar, no browser ou na janela
Lógica de servidor
handlers de postback
métodos [ServerAction], chamados como métodos
Alvos
o browser
o browser, e janelas e telefones nativos a partir da mesma classe
O que guarda do WebForms é a parte que estava certa: um componente é uma classe C# com estado e handlers, e é o framework, não o developer, que decide como ela chega ao ecrã.
public class Counter : StatefulComponent
// lê-se como um handler do WebForms, e corre no browser, sem postback
private void Increment() => SetState(() => _count++);
public override VisualNode Build(ComponentContext context) =>
Row(gap: Space.S2, children: [
Text($"{_count}", TypeRole.Title),
Button("+", onPressed: Increment),
•
TypeScript como intermediário: saída legível, uma segunda verificação de tipos, e source maps de volta ao C#.
•
Bun, embarcado por plataforma: um executável de bundler por SO e arquitetura, entregue como um pacote que o SDK escolhe; sem package.json, nada instalado.
eQuantic.UI.Runtime.OsxArm64/ um pacote por SO e arquitetura
├── tools/bun/bun-darwin.zip o executável, extraído uma vez no primeiro build
└── build/ as props que o SDK lê para o encontrar
•
Um só motor de estilo: estilos C# tipados baixam para CSS atómico deduplicado na web e para pintura em GPU no Photon; não há plano CSS para escrever. Veja Design System.