eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsArchitecture
eQuantic.UI - Architecture
Edit this page
10 min read
eQuantic.UI is a component framework for .NET in which a component is written once, in C#, and realized on two kinds of target: the web, as DOM and CSS served by ASP.NET Core and driven in the browser by JavaScript that the build compiled from that same C#; and native windows and phones, as pixels painted by the Photon GPU engine. There is no second language to learn and no external toolchain to install: the compiler, the bundler and the engine ship inside the SDK's own packages.
TypeScript
C# components, one vocabulary
eqc
Embedded Bun
wwwroot/_equantic
ASP.NET Core SSR
Browser
.NET build, AOT
Photon shell
Window / phone
The diagram above is a mermaid ` fence: GitHub draws it here, and the documentation site draws the SAME fence through the SDK's own Mermaid component.
This page is the map. Write-Once Components explains the shared vocabulary and the realizers in depth, Photon the native engine, Compiler and Build Flow the web pipeline step by step, and Package Architecture what each package carries. The repository keeps the measured audits beside the code: docs/README.md is their index.
Core principles
1.
✅ 100% .NET: no Node.js, npm or Vite on the developer's machine; the toolchain is embedded.
2.
✅ Write once: one component class, realized on the DOM and on a GPU display list.
3.
✅ Self-contained: ASP.NET Core serves, renders and compiles everything for the web.
4.
✅ Familiar: routes are attributes, services come from IServiceCollection, settings from appsettings.json; a .NET developer already knows the idioms.
5.
✅ No platform artifact: no Swift, no manifest, no plist, no CSS. The SDK derives what each target needs from C# declarations.
1. The layers
Layer
Package
What it holds
May reference
The vocabulary
eQuantic.UI.Primitives
the abstract visual nodes (Box, Row, Text, Pressable, …), typed styles and design tokens, the component model, the contract attributes
nothing
The library
eQuantic.UI.Components, eQuantic.UI.Charts
the write-once components (Button, Card, DataTable, BarChart, …), composed only from the vocabulary
Primitives
The web realizer
eQuantic.UI.Web
lowers a node tree to HtmlElement/DOM and atomic CSS on the server; also the DOM escape hatch (HtmlElement, DynamicElement, ClassBuilder)
Primitives
The web host
eQuantic.UI.Server
ASP.NET Core integration: SSR, Server Actions, routing, metadata, assets, hot reload
Web
The browser runtime
@equantic/runtime, bundled by eQuantic.UI.Server from the TypeScript in eQuantic.UI.Runtime
the TypeScript twin of the web realizer, the reconciler, state, the router, and the library's own transpiled modules
nothing
The native track
eQuantic.UI.Native.Engine (+ Metal, Vulkan, Reference), Native.Framework, Native.Components, Native.Hosting, the shells
the GPU engine and its backends, the layout engine, the Photon realizer and host, the application builder, and one shell per platform
Primitives, and each other downward
The compiler
eQuantic.UI.Compiler (eqc)
the Roslyn-based C# → TypeScript transpiler the web SDK runs
Codegen
The SDKs
eQuantic.UI.Sdk, eQuantic.UI.Sdk.Native
MSBuild SDKs: one Sdk="…" attribute brings every package above and wires the build
nothing
Two rules hold the table together. The vocabulary references nothing, so it can ship to every target, including the browser bundle and Native AOT. A realizer never references the library it realizes: a component reaches a realizer as the tree its Build produced, so Web, Email and Native.Components know the vocabulary and not Components. Both rules are tests in the repository (AssemblyLayeringTests), not conventions.
2. The web pipeline
2.1 The SDK
A web app's project file names the SDK and nothing else, with no PackageReference:
1
2
3
4
5
<Project Sdk="eQuantic.UI.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
The version comes from global.json, the way every MSBuild SDK is versioned:
1
2
3
4
5
{
"msbuild-sdks": {
"eQuantic.UI.Sdk": "0.2.0-preview.52"
}
}
Sdk.props imports Microsoft.NET.Sdk.Web, adds the framework packages (Primitives, Code, Components, Charts, Web, Server, the source generator, and the embedded Bun package for the building machine's OS and architecture), puts the declarative surface in scope in every file (eQuantic.UI.Components.UI and eQuantic.UI.Charts.ChartsUI as static usings), and includes Pages/**/*.cs as the components the build compiles for the browser. Beside a source checkout of the framework it swaps each package for the corresponding project, so the samples build against the tree. See Build Flow.
Trimmed to the parts that matter, that file reads:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- eQuantic.UI.Sdk/Sdk/Sdk.props -->
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />
<PropertyGroup>
<EnableEQuanticUICompilation>true</EnableEQuanticUICompilation>
<EQuanticOutputPath>wwwroot/_equantic/</EQuanticOutputPath>
</PropertyGroup>
<ItemGroup>
<!-- the declarative surface, in scope in every file -->
<Using Include="eQuantic.UI.Components.UI" Static="true" />
<Using Include="eQuantic.UI.Charts.ChartsUI" Static="true" />
<!-- what the build compiles for the browser -->
<UIComponent Include="Pages/**/*.cs" Exclude="obj/**;bin/**" />
</ItemGroup>
</Project>
2.2 What dotnet build does
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dotnet build
↓
Microsoft.NET.Sdk.Web, the ordinary pipeline, through CoreCompile (the app's C#)
↓
ResolveBunPath the embedded Bun for this machine, extracted once from its package
↓
CompileEQuanticUI after CoreCompile, and before the static web assets are defined
1. eqc reads the project's sources and the library's shipped sources
2. Roslyn binds them. The semantic model is authoritative; an unbindable call is a build error
3. one TypeScript module per page or component
4. Bun bundles each module; in Debug, with a source map back to the C# (EQuanticSourceMaps)
↓
CopyEQuanticRuntime the served runtime.js, from the Server package
↓
Output: wwwroot/_equantic/, defined as static web assets from what is on disk, so the
compression, the manifests and the publish see this build's files
The TypeScript intermediate is deliberate: the emitted code is readable, typed a second time by the TypeScript compiler, and mapped back to the C# line for the browser's debugger. The bundler is Bun, because it is a single executable that bundles TypeScript with no configuration, and it ships as one package per operating system and architecture (eQuantic.UI.Runtime.OsxArm64, .Win64, .Linux64, …) selected by the SDK. Nobody installs it.
This is what eqc emits for one of the library's own components, Divider, exactly as it ships inside runtime.js. The C# it came from is Divider.cs in eQuantic.UI.Components: the same class, the same Build, the enums lowered to their camelCase names, the object initializer arriving as the trailing props:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { Box, BoxStyle, BuildContext, EdgeInsets, SizeValue, StatelessComponent } from "../runtime-exports";
export class Divider extends StatelessComponent {
declare inset: string;
declare axis: string;
declare leadingInset: any;
constructor(inset: any = 'none', axis: any = 'horizontal', props?: any) {
super();
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';
this.inset = inset;
this.axis = axis;
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 What a build writes
Sizes are not quoted here, since they move with every change: the served runtime's gzip size is a budget the suite enforces (ServedRuntimeBudgetTests, against the record in tests/eQuantic.UI.Server.Tests/Budgets/served-runtime.json), and CI reports each page module's size on every pull request.
File
What it is
runtime.js
the browser runtime: the reconciler, events, state, the Server Actions bridge, the router, AND the shared component library, whose transpiled modules ship inside it. The page reaches it as the bare module @equantic/runtime through the shell's import map, and the Server answers that URL with the bundle it embeds; this file is the same bytes, for the tools that load the modules outside a server (the VS Code preview reads it)
<Component>.js, and .js.map in Debug
one module per page or component, flat. The boot imports the page's module on navigation, so per-route lazy loading falls out of the module graph; there is no pages/ folder. eqc runs bun with --splitting, so what two or more modules share is a chunk named after one of them with a content hash: NotFoundScreen-<hash>.js in the dashboard sample is its console shell, not a second type of that name. The map carries the C# the module came from, so EQuanticSourceMaps is full in Debug and none everywhere else (external writes one without the C#, for an error reporter), and a publish never takes one
strings/<culture>.json
the culture catalogs, when the app has .resx
icons/
the app icon sizes and the web manifest, when an AppIcon is declared
The styles are not a file: the server writes them into the page, the generated token sheet and the atomic classes (<style id="eq-atomic">) among them.
2.4 Server rendering and hydration
The server renders the first HTML from the SAME C#: WebRealizer lowers the component's tree to HtmlElements, and the atomic style classes it writes hash identically to the ones the browser's lowering.ts writes, so hydration compares class names and repaints nothing. A page that implements IServerPrefetch has its data loaded on the server BEFORE the first build, and the answer rides to the browser with the page: the first paint already has data, and there is no loading state to design. See Write-Once Components for the parity contract and Server Integration for the pipeline's options.
3. The native pipeline
The same component class, built by dotnet build into an ordinary .NET application (Native AOT on publish), hosted by Photon:
1
2
3
4
5
6
7
8
9
PhotonApplication.CreateBuilder(args) … .Build().UseRoot<Shell>()
↓
LayoutEngine constraints go down, sizes come up, parents set positions, all in one pass
↓
PhotonRealizer the laid-out tree becomes a display list: rounded boxes, shadows, text, images, icons
↓
RHI one renderer over IRhiDevice; Metal on Apple, Vulkan on Windows and Android, a CPU Reference backend everywhere
↓
Shell macOS, iOS, Android or Windows: the window, input, text services, accessibility bridge, capabilities
Text is shaped by the platform (CoreText, DirectWrite, Android's text layout) behind one ITextMeasurer contract; capabilities (camera, location, storage, biometrics, …) are typed interfaces the shell implements and a component takes through its constructor or GetService<T>(). Hot reload is dotnet watch: the runtime applies the edit in process and the window redraws.
4. Server Actions
4.1 The problem
A page that needs data should not need a controller, a DTO, a fetch and a JSON contract kept in step by hand. It needs a method.
Without it, the same feature is spread over four places that agree only by discipline:
1
2
3
4
5
6
[ApiController]
public class TodoController : ControllerBase
{
[HttpPost("/api/todos")]
public Task<Todo> AddTodo([FromBody] AddTodoRequest request) { /* … */ }
}
1
2
3
4
async function addTodo(title) {
const response = await fetch("/api/todos", { method: "POST", body: JSON.stringify({ title }) });
return await response.json();
}
4.2 The shape
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Pages/TodoList.cs
[Page("/todos")]
public class TodoList : StatefulComponent
{
private List<Todo> _todos = [];
// Runs on the SERVER. The client gets a call, not the body.
[ServerAction]
public async Task<List<Todo>> LoadTodos()
{
using var db = new AppDbContext();
return await db.Todos.ToListAsync();
}
[ServerAction]
public async Task<Todo> AddTodo(string title)
{
using var db = new AppDbContext();
var todo = new Todo { Title = title };
db.Todos.Add(todo);
await db.SaveChangesAsync();
return todo;
}
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); // type-safe, serialized for you
SetState(() => _todos.Add(added));
}
public override VisualNode Build(ComponentContext context) =>
Column(gap: Space.S2, children: [.. _todos.Select(todo => TodoItem(todo))]);
}
The compiler replaces the body of every [ServerAction] with a call to the bridge:
1
2
3
async loadTodos() {
return await this._serverActions.invoke("TodoList/LoadTodos", []);
}
4.3 The bridge
app.UseServerActions() answers POST /api/_equantic/actions. Only methods marked [ServerAction] are callable (a whitelist, not a convention), and [Authorize] is enforced before the method runs. Payload size and argument types are validated; see Security. A Server Action is request/response; what the server publishes while a page is open reaches it over server events.
On the wire it is one POST per call, which is worth knowing when you read the network tab:
1
{ "actionId": "TodoList/LoadTodos", "arguments": [] }
1
{ "success": true, "result": [ { "id": 1, "title": "Buy milk" } ] }
4.4 Assets
A component that needs an external script or stylesheet declares it, and the server injects the tags once per page, deduplicated:
1
2
3
4
5
6
7
8
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> does the same for a component you did not write, registered in DI or found by AddUI()'s assembly scan:
1
2
3
4
5
6
7
8
public class PrismAssetProvider : IComponentAssetProvider<CodeBlock>
{
public void ConfigureAssets(AssetBuilder assets) =>
assets.AddScript("https://cdn.example.com/prism.js");
}
// explicit registration, when the scan should not be the one to find it
options.WithAssetProvider<PrismAssetProvider>();
The flow: ServerRenderingService walks the rendered tree collecting both kinds, AssetCollection deduplicates by key (stylesheets first, then scripts), and the tags land in <head>. A page with no asset-requiring component gets none. See Assets.
5. Routing
A route is an attribute on the page, discovered at build time, so there is no route table to keep in step with the pages:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Page("/counter")]
[Page("/count")] // several routes, one page
public class Counter : StatefulComponent { }
[Page("/user/{id:int}")] // ASP.NET route syntax, parameters included
public class UserProfile : StatefulComponent
{
public override VisualNode Build(ComponentContext context)
{
var id = context.Route.Param("id"); // what the route said, with no target in it
var page = context.Route.Query("page"); // and the query string
…
}
}
[Page("/admin/dashboard")]
[Authorize(Roles = "Admin")]
public class AdminDashboard : StatefulComponent { }
1
2
3
4
5
6
7
8
9
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddUI(options => options.ScanAssembly(typeof(Program).Assembly));
var app = builder.Build();
app.UseStaticFiles();
app.UseServerActions();
app.MapUI(); // every [Page], plus the SPA fallback
app.Run();
In the browser the runtime's router handles navigation without a reload: a Link is a real anchor, and Navigator.Go(destination) is the programmatic twin for the routes a component decides to take itself. On Photon the same Link and Navigator reach the host's navigation seam.
6. Developer experience
6.1 A project
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MyApp/
├── MyApp.csproj # <Project Sdk="eQuantic.UI.Sdk">
├── global.json # the SDK version
├── Program.cs # the ASP.NET Core host
├── Pages/ # [Page] components
│ ├── Home.cs
│ └── Counter.cs
├── Components/ # your own write-once components
├── Assets/ # AppIcon.cs, *.svg → vectors at build time
├── Resources/ # .resx → strings/<culture>.json
└── wwwroot/
└── _equantic/ # generated by the build; the .map files in Debug
├── runtime.js
├── Counter.js Counter.js.map
└── Home.js Home.js.map
6.2 Commands
1
2
3
4
5
6
dotnet new install eQuantic.UI.Templates # once
dotnet new equantic-app -n MyApp # a web app
dotnet new equantic-native -n MyApp # a Photon app
dotnet watch run # hot reload, web or native
dotnet build # C# → TypeScript → JavaScript, SSR ready
dotnet publish -c Release # a publishable app; Native AOT for Photon
6.3 Hot reload
1
2
3
4
5
6
7
8
1. You edit Counter.cs
2. the server, watching the app's .cs, runs eqc again and Counter.js is rewritten in place;
dotnet watch applies the same edit to the running server meanwhile
3. the server pushes an event on /_equantic/hmr, server-sent events with a heartbeat and reconnection
4. the browser fetches the new module and MOUNTS the page again, with the fields the page held;
the old server markup is not re-hydrated
5. on Photon there is no step 3: the runtime applies the edit inside the running process,
and the host redraws on the next frame
See Debug for the error overlay and the source maps that point back to the C# line.
7. Compared with WebForms
Aspect
WebForms
eQuantic.UI
State
ViewState in a hidden field
state on the component instance, kept by the reconciler; the server holds data, not view state
Rendering
server-generated HTML on every postback
server-rendered first paint, then a client-side reconciler
Updates
full-page postback
in-place, in the browser or in the window
Server logic
postback handlers
[ServerAction] methods, called like methods
Targets
the browser
the browser, and native windows and phones from the same class
What it keeps from WebForms is the part that was right: a component is a C# class with state and handlers, and the framework, not the developer, decides how it reaches the screen.
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Counter : StatefulComponent
{
private int _count;
// reads like a WebForms handler, and runs in the browser, with no postback
private void Increment() => SetState(() => _count++);
public override VisualNode Build(ComponentContext context) =>
Row(gap: Space.S2, children: [
Text($"{_count}", TypeRole.Title),
Button("+", onPressed: Increment),
]);
}
8. Technical decisions
•
TypeScript as the intermediate: readable output, a second type check, and source maps back to C#.
•
Bun, embedded per platform: one bundler executable per OS and architecture, delivered as a package the SDK selects; no package.json, nothing installed.
1
2
3
eQuantic.UI.Runtime.OsxArm64/ one package per OS and architecture
├── tools/bun/bun-darwin.zip the executable, extracted once on the first build
└── build/ the props the SDK reads to find it
•
One styling engine: typed C# styles lower to deduplicated atomic CSS on the web and to GPU paint on Photon; there is no CSS plane to author. See Design System.
•
Write once, realize per target: the vocabulary is closed and sits below every realizer; the realizers are the extension point, not the nodes. The trade-offs this costs, measured, are in the repository's architecture audit and its companion, how Flutter solves each of these.