Skip to content

WriteOnceComponents

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

Write-Once Components (Shared Architecture)

The core promise of eQuantic.UI's multi-target story: components are authored once, in C#, against an abstract vocabulary — and realized per target: DOM + CSS on the web, GPU pixels through Photon on native. Not "two similar APIs" — literally the same class.

This page is the architecture overview; the component catalog lives on the Components page, and how you WRITE a tree — factories rather than new, including for your own components — on Declarative Surface.

The layers

Package Role Depends on
eQuantic.UI.Primitives The shared foundation: colors, design tokens, typed styles, abstract nodes, component model — (zero)
eQuantic.UI.Components The write-once component library Primitives only
eQuantic.UI.Web Web realizer: abstract tree → HtmlElement/DOM + CSS (SSR) + generated stylesheet Core, Primitives
eQuantic.UI.Native.Components Native realizer: abstract tree → Photon display list Primitives, Engine
TS runtime (@equantic/runtime) Client-side lowering (hydration) + embedded library modules

The abstract vocabulary

Structure: Box (+BoxStyle — paint, opacity, transform, aspect-ratio, hover/focus diffs), Row/Column (gap-owned flex + Wrap/RunGap), Grid (+GridTrack — the CSS Grid twin), Stack/Positioned (z-order + ZIndex), AdaptiveNode (window size classes, zero listeners), ScrollView (+Sticky), Overlay (viewport layer, Modal flag), Anchored (floating panels: placements, outside-tap scrim, MatchAnchorWidth, OpenOnHover). Content: Text (type roles), TextEntry, Icon (curated + any pack glyph), Image, Spinner. Interaction & motion: Pressable (≥48dp hit contract), Link (real anchors on web, the host navigation seam on native), DragDismiss (the sheet gesture), LoopMotion, Presence (enter/exit motion). Plus value types (EdgeInsets, SizeValue Hug/Fill/Fixed, CornerRadii, ColorToken light/dark pairs, StyleDiff, Transform2D) and the component model (StatelessComponent, StatefulComponent + SetState, mode-free ComponentContext).

Styling has no CSS plane — see the atomic style engine in DesignSystem: every style declaration becomes one deduplicated atomic class, byte-identical between SSR (C#) and hydration (TS).

Layout parity is a contract: the C# flex engine (native) and CSS flex (web) implement the same spec — leftover-by-weight Flexible, the truncation contract (text shrinks to ellipsis before pushing siblings), stretch-fills-auto-only.

Since 0.2.0-preview.6

Responsive panes: Flexible with a basis

Flexible(child, flex, basis, shrink) is the CSS flex: grow shrink basis triple. The basis is the size a wrapping parent breaks lines against, and it is what makes a responsive layout expressible at all:

// `Wrap` is an init property, so this is the constructor + initializer form —
// the factories cover constructors, never a second API.
var panes = new Row(gap: Space.S4) { Wrap = true, Width = SizeValue.Fill };
panes.Add(Flexible(EditorPane(), flex: 1, basis: 440));
panes.Add(Flexible(PreviewPane(), flex: 1, basis: 380));

Wide enough for both bases, the panes share a line and split the leftover by weight. Too narrow, each takes a line of its own and grows to fill it — rather than sitting at 440 with the rest of the line empty. That second part is a real pass, not a side effect of line-breaking: leftover goes to the growers by weight, an overflowing line is taken back from the shrinkers weighted by basis (as CSS scales it), and nothing crosses the min-content floor.

A basis of 0 — the default — is the historical behaviour: the child contributes nothing of its own and is sized purely from its weight. shrink: 0 refuses to give space back, so a line that cannot fit wraps instead of squeezing. Components author tokens, never resolved colors — one built tree realizes in light or dark.

How the same class reaches both targets

Native: PhotonHost expands Build() inline during layout and the realizer emits draw commands.

Web, server (SSR): the WebRealizer lowers the tree to HtmlElements. Colors become light-dark(#l, #d) so the DOM stays mode-free (theme switch = color-scheme). Core pages can embed write-once subtrees via the VisualNodeComponent bridge — and a Primitives StatefulComponent with [Page] is a full page (the server bridges it automatically).

Web, client: eqc (the C#→JS compiler) transpiles the same sources; the runtime's lowering (lowerVisualNode) mirrors the WebRealizer rule-for-rule — hydration parity is enforced by cross-pinned byte-exact style strings asserted in both the C# and TS test suites.

One failure, one subtree

Every target expands a component through the SAME seam, and that seam is a boundary: if Build throws, the failing component's subtree is replaced by a contained surface and everything around it still renders. A page that has no parent to contain it carries the same boundary at its own render seam, so a broken page is a panel, never a blank document.

before now
Server (SSR) 500 for the whole request 200, panel in place of the subtree
Browser mount threw, root never written panel, siblings interactive
Window (Photon) frame never arrived panel, the app keeps presenting

The panel is built from the vocabulary, so it is the same surface on a page and in a window. In development it names the component and quotes the throw; in production it says only that a section could not be displayed — an exception message is written for whoever wrote the code, and can name an id, a path or a query. Either way the failure reaches the host's log through ComponentBoundary.Report: a boundary that only swallows trades a loud crash for a silent one.

Nothing is remembered. A component that stops throwing — a retry, new props, a hot reload — simply builds again on the next pass.

A component's own CONTRACT is not a render-time failure and does not belong here: state it where the mistake is written (init => field = value.Count > 3 ? throw … : value), so the author gets an exception at the line that got it wrong instead of a red panel once per frame.

The library is runtime-provided

The transpiled eQuantic.UI.Components modules ship inside runtime.js, byte-pinned in CI against the live eqc output — apps import them from @equantic/runtime. User-authored write-once components need no wiring: they live in the app and flow through the normal page scan (eqc detects the Primitives component shapes, including direct-SetState stateful).

Generated, never hand-written

Every design-system value on the client is generated from the C# single source and byte-pinned:

  • PhotonCssGenerator → the normative stylesheet (custom properties, type-role classes, elevation, motion).
  • DesignSystemTsGeneratordesign-system.generated.ts (tokens, theme, Button size table).
  • IconTsGeneratoricons.generated.ts (glyph path data from the C# IconRegistry).

See DesignSystem.

The library — one library, written once

There is exactly ONE component library. Buttons, cards, inputs, navigation, overlays, lists, the recycling ListView, the Spreadsheet grid, the code editor — the full catalog, grouped and described, is on the Components page.

Each component ships with web realizer pins, native goldens (light+dark), pinned transpiled fixtures executed in vitest, and the live showroom (/ and /shared in DefaultUIDashboard — SSR + hydration identity + interaction verified end-to-end by the Playwright suite). Systems shipping alongside the library: the state-transition motion system (Presence enter/exit), the pointer pipeline (hover, drag-to-dismiss with slop/cancel/glide), the scroll compositor (real Sticky pinning) and anchored overlays.

A link inside a sentence

Since 0.2.0-preview.12

TextRun.Destination — because a link in the middle of a paragraph has nowhere else to live:

new Text("", TypeRole.BodyM)
{
    Spans =
    [
        new TextRun("Read the "),
        new TextRun("getting started") { Destination = "/docs/start", Weight = FontWeight.SemiBold },
        new TextRun(" guide."),
    ],
}

A Link around the whole Text makes the entire paragraph one link. A Row of Texts breaks between runs rather than between words, so a sentence with three code spans wraps at the spans — which is why mixed emphasis has to be one Text with Spans to begin with. Between those two, a mid-sentence link was inexpressible, and for prose that is most paragraphs.

A run can also be a different SIZE from the prose around it — StyleOverride on the run, the same escape hatch Text.StyleOverride is. Inline code at 13.5 inside a 16 paragraph is the case, and the run keeps the paragraph's line box: setting its own line-height would open a gap above and below, which is a line, not a run. Since 0.2.0-preview.15.

The SHAPE is universal: an Apple NSAttributedString carries a .link attribute on a range, an Android Spannable carries a URLSpan. An inline link is a run with an attribute everywhere, and cannot be a separate node — a separate node is what breaks the line.

The NAME follows from the same rule. Apple says link, Android says URLSpan, HTML says href — so href is one target's word, and the abstract layer does not speak any target's language (Pressable, not "Button"). It is Destination on both Link and TextRun: what the value IS, which is a route or a URL, in a word none of the three targets owns.

Renamed in 0.2.0-preview.12, from Href. A breaking change taken while it is one line per app rather than after there are apps to break.

Spans is web-realized today. Photon realizes Link — it collects link regions and hit-tests them, handing the destination to the shell's navigation seam — but not the per-RUN destination, so a paragraph with inline links draws there as its text, unlinked. What is missing is per-run hit testing, not meaning.

The route a page is on

Since 0.2.0-preview.13

A page on /docs/{slug} reads the slug from its own context:

[Page("/docs/{slug}")]
public sealed class DocPage : StatelessComponent, IServerPrefetch
{
    private Doc? _doc;

    [ServerOnly]
    public async Task PrefetchAsync(IServiceProvider services, CancellationToken cancellationToken)
        => _doc = await services.GetRequiredService<IDocs>()
            .FindAsync(RouteValues.Current.Param("slug"), cancellationToken);

    public override VisualNode Build(ComponentContext context) =>
        Text(_doc?.Title ?? "Not found", TypeRole.Heading);
}
  • context.Route.Param("slug") in Build, RouteValues.Current.Param("slug") in a prefetch (which runs before there is a context).
  • context.Route.Query("page") for the query string.
  • Never null; an unmatched name answers null.

Before this the only way there was IHttpContextAccessor. That works, and it costs the page the thing that makes it write-once: a component that knows about ASP.NET is a component that cannot run on Photon. Reading a route parameter is the most ordinary thing a page does, and if the way to do it is web-specific then "write-once" holds for the visuals and not for the page.

The route is armed before the prefetch, which is the part that matters: a page on /docs/{slug} loads by the slug, so a route arriving after the prefetch arrives after the only question it was there to answer.

Per request, on an AsyncLocal like every other per-request value — SSR renders concurrent requests on shared instances, and a slug held in a field would hand one visitor's document to another.

Needs 0.2.0-preview.14 to be read from Build. On preview.13 the runtime had no RouteValues export, so a page naming it outside [ServerOnly] died at hydration on "does not provide an export named 'RouteValues'" — while SSR kept answering 200 with correct markup, which is why it looked like nothing was wrong. Reading it from a [ServerOnly] prefetch works on both.

Entering the tree, and leaving it

Since 0.2.0-preview.13

public sealed class LiveRates : StatefulComponent
{
    private IDisposable? _subscription;
    private NetworkState _network;

    protected override void OnMount() =>
        _subscription = _status.Subscribe(state => SetState(() => _network = state));

    protected override void OnUnmount() => _subscription?.Dispose();

    public override VisualNode Build(ComponentContext context) =>;
}

A constructor cannot be the place to load stored state, subscribe to a device, or start a request: the reconciler builds fresh instances every pass and keeps the retained one, so anything a constructor starts is started again for an instance that is then thrown away. OnMount runs once, on the instance that stays.

OnUnmount ships with it rather than after it, because without the second every mount is a leak.

Three things worth knowing:

  • Delivered when the pass closes, not mid-build. A hook running inside the reconcile runs while its own parent is still building, so a SetState from there would mutate a tree half-way through being produced.
  • Outgoing unmounts before incoming mounts — the order that lets two components share one exclusive resource across a swap.
  • It does not mean "the pixels exist." Photon has no DOM to read geometry from, so a hook promising it could not be write-once. It means: this component is in the tree, its build is about to be shown, and a SetState from here schedules the next pass instead of fighting the current one.

A page ROOT has no parent to mount it, so the surface does: PhotonHost after its first frame, the web base on mount/hydrate.

Testing a tree without a server

A component is a value, so asserting on what it renders costs an assert, not a process. The web realizer is public and the framework's own tests use exactly this:

using eQuantic.UI.Core.Rendering;
using eQuantic.UI.Web;

var html = HtmlRenderer.RenderNode(
    WebRealizer.Lower(new PriceTag(1999), PhotonTheme.Instance).Render());

html.Should().Contain("R$ 19,99");

Three steps, because each does one thing: Lower(node, theme) realizes the tree, .Render() turns the realized element into an HtmlNode, and HtmlRenderer.RenderNode(...) writes that node as markup. Stopping at .Render() gives you the node — useful when the assertion is about structure (.Tag, .Attributes) rather than text, which is what most of the framework's own tests want. Without a style sink the styles stay inline, which is what you want in a test — the atomic-class form is for the page, and asserting on a content hash is asserting on a build artefact.

Two things this buys beyond speed. A test can render the SAME node for both realizers and compare what each did with it, which is how the layout parity contract is held. And a component that throws is caught by the boundary, so a broken subtree fails its own assert instead of taking the suite with it.

Clone this wiki locally