eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
START HERE
Getting Started
WRITE-ONCE
Write-Once Components
Declarative Surface
Photon Engine
Design System
Capabilities
Storage
Forms
Code Editor
Markdown
Mermaid
Email Rendering
ARCHITECTURE
Architecture Overview
Package Architecture
Components
Styling
Localization
Analytics & GTM
COMPILATION
Compiler
Compile-Time Evaluation
Supported C# Features
External Type Resolution
Build Flow
Diagnostics
RUNTIME
Runtime (TypeScript)
Performance
SERVER
Server Integration
Assets
BunPackage
Security
ECOSYSTEM
Image
Charts
Icons
Lottie
DEVELOPMENT
Visual Editor
Debug
Roadmap
PT-BR
Analytics-pt-BR
Architecture-pt-BR
Assets-pt-BR
BuildFlow-pt-BR
BunPackage-pt-BR
Capabilities-pt-BR
Charts-pt-BR
CodeEditor-pt-BR
Compiler-pt-BR
CompileTimeEvaluation-pt-BR
Components-pt-BR
Debug-pt-BR
DeclarativeSurface-pt-BR
DesignSystem-pt-BR
Diagnostics-pt-BR
EmailRealizer-pt-BR
ExternalTypeResolution-pt-BR
Forms-pt-BR
GettingStarted-pt-BR
Home-pt-BR
Icons-pt-BR
Image-pt-BR
Localization-pt-BR
Lottie-pt-BR
Markdown-pt-BR
Mermaid-pt-BR
PackageArchitecture-pt-BR
Performance-pt-BR
Photon-pt-BR
Roadmap-pt-BR
Runtime-pt-BR
Security-pt-BR
ServerIntegration-pt-BR
Storage-pt-BR
Styling-pt-BR
SupportedFeatures-pt-BR
VisualEditor-pt-BR
WriteOnceComponents-pt-BR
ACTIONS & INPUTS
Button
IconButton
TextInput
Select
Checkbox
Switch
RadioGroup
SegmentedControl
Slider
Stepper
SearchField
SURFACES & DISPLAY
Card
Badge
Chip
Avatar
Banner
ProgressBar
EmptyState
Divider
NAVIGATION
Tabs
AppBar
BottomNavigation
Breadcrumb
Pagination
PageIndicator
Menu
Drawer
OVERLAYS
Dialog
BottomSheet
Toast
Popover
Tooltip
LISTS & DATA
List
ListView
Table
Accordion
CodeBlock
TOUCH INTERACTION
PullToRefresh
SwipeableRow
DocsWrite-once
The Declarative Surface (authoring without new)
Edit this page
6 min read
🌐 This page in: English · Português
A screen is plain C# expressions: no markup language, no builder ceremony, and no new:
1
2
3
4
5
6
7
8
public override VisualNode Build(ComponentContext context) =>
Column(gap: Space.S4, children: [
Text($"Count: {_count}", TypeRole.Display, context.Theme.TextPrimary),
Row(gap: Space.S3, children: [
Button("Up", onPressed: () => SetState(() => _count++)),
Button("Reset", Variant.Outline, onPressed: () => SetState(() => _count = 0)),
]),
]);
Every name there is a factory method named exactly like the type it returns. There is no import to write: the SDK puts the framework's surface in scope in every file of your project, and your own components join it automatically (see below).
Because styles are typed values rather than CSS strings, the compiler checks the whole interface, layout and styling included, and the same class renders on the web and natively through Photon.
The contract
A factory is named exactly like its type. Column, Text, Button.
It mirrors a constructor parameter for parameter (same names, same order, same defaults), so named arguments carry between new X(…) and X(…) unchanged.
After the mirrored parameters, a factory may take an optional semantic tail: parameters that each match a public init-only property of the node (same name, same type) and are applied via an object initializer. This is how a declarative screen states what constructors deliberately do not carry — `Pressable(child, onPressed, label:, selected:, disabled:, pressedBackground:, expanded:), Link(destination, child, label:, current:), and TextEntry(value, onChanged, label:, placeholder:, disabled:, obscure:). Composite and modal machinery (Role, Mixed`, InitialFocus) stays initializer-only on purpose: it belongs to the components that own those patterns.
Container nodes take a final trailing children parameter, written as a collection expression.
There are no overloads. The surface transpiles to a JavaScript twin, and JS methods cannot overload, so each type has ONE canonical factory.
Layout is parameters, not an initializer
Since 0.2.0-preview.13
1
2
3
Row(gap: Space.S3, main: MainAlign.SpaceBetween, cross: CrossAlign.Start, children: [ ])
Column(gap: Space.S2, wrap: true, runGap: Space.S4, children: [ ])
Text("42", TypeRole.Display, align: TextAlignment.Center, tabular: true)
Semantics are parameters too
Since 0.2.0-preview.45
An icon-only button VoiceOver can name, a nav item that STATES it is selected, the link to the page the reader is on — all reachable without leaving the factory form:
1
2
3
Pressable(Icon(Icons.Search), onPressed: Search, label: "Search", selected: true)
Link("/scan", Icon(Icons.Home), label: "Home", current: true)
TextEntry(_password, OnPassword, label: "Password", obscure: true)
Layout is parameters too (next release)
A container that fills its parent — the most common layout there is — no longer needs new: Column, Row, Grid, Stack, ScrollView and ListView take width and height before children (a SizeValue; leaving them out is Hug, exactly what an initializer without them means), and Pressable takes its composite role. Counted by a real app after migrating its whole UI: 49 places that had stayed imperative for a width, 3 for a role.
1
2
3
Row(gap: Space.S2, width: SizeValue.Fill, children: [ ])
Column(height: SizeValue.Fill, children: [ ScrollView(list, height: SizeValue.Fill) ])
Pressable(Text("Overview"), Select, selected: true, role: PressableRole.Radio)
Because Stack gained two knobs before children, its children is named — Stack(Alignment.Center, children: [ … ]) — the rule every other container already followed.
Row and Column take main, cross, wrap, runGap and padding; Text takes align, mono, tabular and styleOverride. These were init-only properties, so setting one meant an object initializer, which means new, and new is exactly what this surface removes. A row that had to centre its content dropped out of the surface entirely and had to be written the old way.
Width, height, background and corner radius are deliberately not parameters on a flex: a flex carrying those is a Box wrapping a flex, and the properties already say so.
styleOverride on Text is the way out of a closed type scale. The rungs are the right default and a design that reaches past them everywhere has stopped having a scale, but a closed scale with no way out gets worked around by nesting a raw HtmlElement, which is worse, because it only works on one target.
BREAKING IN 0.2.0-PREVIEW.13 children is trailing (the container contract), so the knobs sit between it and gap: Column(Space.S3, [ … ]) becomes Column(Space.S3, children: [ … ]). One word, and it is the surface's normal form already.
Rarer init properties keep the constructor + initializer form; the factories are sugar over the same types, never a second API:
1
2
Box(new BoxStyle { Padding = EdgeInsets.All(Space.S4), Background = theme.Surface },
Text("Still the same Box", TypeRole.BodyM))
Value records (GridTrack, DialogAction, NavItem) deliberately have no factories: they are data, and target-typed new(…) already reads well.
Your own components join it
Define a component; the build generates its factory. Nothing to register, nothing to import:
1
2
3
4
5
6
7
8
// Components/StatTile.cs
public sealed class StatTile : StatelessComponent
{
public StatTile(string label, string value) { Label = label; Value = value; }
public string Label { get; init; }
public string Value { get; init; }
public override VisualNode Build(ComponentContext context) => /* … */;
}
1
2
3
4
5
// Pages/HomePage.cs, no using, no new
Row(gap: Space.S3, children: [
StatTile("Count", $"{_count}"),
StatTile("Doubled", $"{_count * 2}"),
])
A source generator reads the compilation, finds the components, and writes a static AppUI class plus the global using static that puts it in scope. It stays in step with your components because it is generated from them.
Pages get no factory. A [Page] is reached by its route, never composed by hand.
Which constructor is mirrored
The widest, the same rule the transpiler applies when it collapses constructor overloads, so the factory and the emitted constructor never disagree. When the widest is not the one you want offered, elect another:
1
2
3
4
5
6
public sealed class Badge : StatelessComponent
{
[UiFactory]
public Badge(string label) { } // ← this one gets the factory
public Badge(string label, int count, bool dot) { }
}
Diagnostics
Code
Severity
Meaning
EQ3101
Error
Two constructors of one component are marked [UiFactory]. An election needs a single winner.
EQ3102
Warning
Two components share a name, so only one can own that factory. Rename one, or build the other with new.
Turning it off
1
2
3
<PropertyGroup>
<EQuanticGenerateFactorySurface>false</EQuanticGenerateFactorySurface>
</PropertyGroup>
Your components then compose with new, exactly as before.
The one sharp edge: a factory shadows its type
A method named like a type shadows that type in any file where the surface is in scope. It bites only on types that arrive through a using, which means the framework's, not yours:
1
2
Spacer.Fixed(34) // ✗ CS0119: `Spacer` binds to the factory method
Panel.Empty("x") // ✓ your own type, declared in your own namespace, wins
C# resolves names declared in the current namespace ahead of using static imports, so your own components are never shadowed by their own factories, and you never need the workaround below.
The framework's own statics that sit behind a factory name get a factory under a name of their own:
Instead of
Write
Spacer.Fixed(34)
Gap(34)
Badge.AsDot(variant)
DotBadge(variant)
new Icon(packGlyph)
Glyph(packGlyph)
Since 0.2.0-preview.7 (the Glyph row; the other two since 0.2.0-preview.2)
A conformance test walks every factory, looks for statics on the type it shadows, and fails naming them until each has a named factory, so a fourth one cannot appear unnoticed.
Glyph is there for a slightly different reason than the other two, and it is worth knowing which: Icon has two constructors, one taking the framework's curated Icons enum, one taking the IconGlyph an icon package hands out. The mirrored Icon(...) can only be one of them, and there are no overloads here, so a pack glyph had no way into a file importing this surface at all: it could only be drawn with new, in the one place the framework promises you never need it.
1
2
3
4
using eQuantic.UI.MaterialSymbols;
Glyph(MaterialSymbolsIcons.PlayArrowRounded) // any icon package's catalog
Icon(Icons.Check) // the curated set
How it reaches the browser
The generated surface is written to disk (EmitCompilerGeneratedFiles) because eqc reads files, not the C# compilation: a factory your page calls has to be part of what the transpiler sees, or the call resolves to nothing and the emitted JavaScript degrades silently. It is then transpiled into a module like any other class, so AppUI.statTile(…) exists in the bundle beside UI.column(…).
IF YOU RENAME OR REMOVE A GENERATOR, run dotnet clean. Compiler-generated files are not removed when the generator that wrote them goes away, and eqc would still read the leftovers: a phantom type, or a second copy of a live one.
See also
Write-Once Components: the architecture the surface sits on
Components: the catalog every factory corresponds to
Design System: the typed tokens the arguments are made of