Skip to content

DeclarativeSurface

Edgar Mesquita edited this page Aug 9, 2026 · 2 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.

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)

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 third one cannot appear unnoticed.

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