Skip to content

DeclarativeSurface

Edgar Mesquita edited this page Aug 10, 2026 · 4 revisions

The Declarative Surface (authoring without new)

A screen is plain C# expressions — no markup language, no builder ceremony, and no new:

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.
  • Container nodes take a 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

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)

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:

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:

// 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) => /* … */;
}
// 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:

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

<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:

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)
Since 0.2.0-preview.7

| new Icon(packGlyph) | Glyph(packGlyph) |

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.

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

Clone this wiki locally