-
Notifications
You must be signed in to change notification settings - Fork 1
WriteOnceComponents
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.
| 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 | — |
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
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.
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.
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 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).
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). -
DesignSystemTsGenerator→design-system.generated.ts(tokens, theme, Button size table). -
IconTsGenerator→icons.generated.ts(glyph path data from the C#IconRegistry).
See DesignSystem.
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.
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.
Spansis web-realized today. Photon realizesLink— 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.
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")inBuild,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 noRouteValuesexport, 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.
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
SetStatefrom 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
SetStatefrom 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.
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.