eQuantic.UI Styling ArchitectureeQuantic.UI provides a Flutter-inspired developer experience (DX) while leveraging the full power of each rendering target. The styling architecture is built on three pillars:
1.
Abstraction: Components author _what_ to style, as typed C# values, never CSS strings.
2.
One semantics, two targets: the same style vocabulary drives dp values on native (Photon) and CSS on the web.
3.
Performance: build-time class generation and deduplication, with no CSS-in-JS runtime overhead.
1. Styling Components: Typed C#, No CSS Plane
Component styling is authored entirely in typed C# (BoxStyle, StyleDiff, design tokens) and lowered by the atomic style engine: every regular declaration becomes ONE deduplicated atomic class, byte-identical between SSR (C#) and hydration (TS); theme colors reference var(--eq-color-*) custom properties. Hover/focus states, window-size-class adaptivity, sticky positioning and transforms are all part of the vocabulary, with zero JavaScript involved.
Hover is derived, and it is pointer-only
Since 0.2.0-preview.24
The variant palette stays five sub-tokens; hover resolves by DERIVATION: VariantColors.Hover is the per-channel midpoint of Base → Pressed (ColorToken.MidpointWith, the same color color-mix(in srgb, Base 50%, Pressed) lands on), and quiet variants hover on SurfaceSubtle. Components author it as an ordinary BoxStyle.Hover diff, so both realizers already knew how to paint it: the web as an atomic :hover rule, Photon through the pointer pipeline.
Two rules ride the emission itself. Every atomic :hover rule is wrapped in @media (hover: hover), so a touch browser's sticky emulated hover finds no rule, and the gate wraps the rule, never the hash, so SSR and hydration still compare identical class strings. And on Photon, hover is tracked by layout PATH as a CHAIN of everything under the pointer, because CSS :hover matches every ancestor: hovering a tooltip's trigger hovers the tooltip too, and a component rebuild, which replaces every node instance, no longer drops the hover it repainted.
A colour a component derives runs in the browser
Since 0.2.0-preview.60
The browser holds a Color as plain data, { r, g, b, a }, because the runtime's styles, the generated design system and a hydration payload all make one without a constructor. A component's browser-side code still writes it as C#: Brand.WithOpacity(0.8f), a.MidpointWith(b), new Color(…), default(Color), $"{color}", and a method group such as Func<float, Color> fade = Brand.WithOpacity. eqc lowers each one to the runtime's Color, and the text is the record text .NET writes, Color { R = 248, G = 113, B = 113, A = 204 }.
Before 0.2.0-preview.60 these rendered on the server and threw in the browser once the page hydrated (withOpacity is not a function), and an app's own type named Color was built as the vocabulary's.
A curve a component makes runs in the browser
Since 0.2.0-preview.61
A Curve is plain data in the browser too, { x1, y1, x2, y2 }, wherever the browser meets one: a preset, a motion role's curve, a transition's easing, a curve a component makes, a curve in a page's state. So a component's browser-side code reads and makes a curve as C# does: Curve.Standard.X1, Motion.Press.Curve, new Curve(0.2f, 0.9f, 0.3f, 1.25f) as a TransitionSpec's Easing, ==, with, a deconstruction, and $"{curve}", which prints Curve { X1 = 0.2, Y1 = 0, X2 = 0, Y2 = 1 } as .NET does.
var easing = new Curve(0.2f, 0.9f, 0.3f, 1.25f);
Transition = new TransitionSpec(StyleChannels.Colors, 150) { Easing = easing },
The evaluator stays on the server and in the native shells. A web transition is a CSS timing function, so the browser evaluates the curve itself, and a component that calls curve.Ease(t) or CurveEvaluator.Ease(curve, t) fails the build with EQ2010. Hand the curve to a TransitionSpec, or call the evaluator from server code.
Before 0.2.0-preview.61 a preset was an array in the browser and a motion role's curve a name, so a point read undefined, new Curve(…) threw "is not a constructor" once the page hydrated, and curve.Ease(t) built and threw.
A state changes any member of the style
Since 0.2.0-preview.60
A StyleDiff is what a box looks like while a state is active: BoxStyle.Hover under the pointer, and BoxStyle.Focus and BoxStyle.Pressed while its control is focused or pressed (below). Every member it sets applies, on the web and on Photon, and every member it leaves unset keeps the base's value. Until this release a hover on Photon applied only its background and border, and on the web it could not move a box or change its own shadows.
Background = theme.Surface,
Shadows = [new ShadowSpec(12, 30, -12, glow)],
Transition = new TransitionSpec(StyleChannels.Transform | StyleChannels.Shadow),
Transform = Transform2D.Translate(0, -2),
Shadows = [new ShadowSpec(16, 36, -12, glow)],
That card lifts 2dp and deepens its glow under the pointer, and glides both ways. What each member does while the state is active:
•
Background, BorderColor, BorderWidth, Opacity and BackdropBlur replace the base's.
•
Transform replaces the base's Transform, the way a CSS rule replaces transform: a card rotated at rest and lifted on hover states both in its hover, Transform2D.Rotate(2).WithTranslate(0, -2), and Transform2D.Scale(1) undoes the resting one.
•
Elevation and Shadows change their part of ONE shadow list, and the rest of the list stays: the elevation's shadow, then the custom shadows (the state's replace both of the base's Shadow and Shadows), then the inset highlight. A state that leaves nothing to draw writes a list that draws nothing.
•
Gradient replaces the base's gradient and keeps the base's glow and grid pattern under it.
A shadow with no offset, blur or spread draws nothing, so it is left out of the list on both targets. Under a Transition, both targets glide the opacity, the transform, the colours and every shadow, custom ones included, and a shadow a state adds or takes away fades in or out (Photon since 0.2.0-preview.61). A lifted box also takes its hit area with it on the web, as any CSS transform does, so a pointer resting on the edge the box moves away from can lift it off and drop it back. Photon still hit-tests a transformed box where it was laid out, its resting transform included (#513). Keep a lift small, or put the hover on a box that does not move. StyleDiff is a class since this release (it was a struct): Hover!.Value.Background becomes Hover!.Background, and a box that declares no state carries a null in its place.
A control's press and focus
Since 0.2.0-preview.61
A hover is the box's own state: the pointer is over it. A press and a keyboard focus happen to a CONTROL, the Pressable, so Pressed and Focus apply to every box inside the control while it is pressed or focused: the surface, and anything in it that says how it looks then.
Pressable(Box(new BoxStyle
Background = theme.Surface,
Transition = new TransitionSpec(StyleChannels.Transform, Motion.BaseMs),
Pressed = new StyleDiff { Transform = Transform2D.Scale(0.985f) },
Focus = new StyleDiff { BorderColor = theme.FocusRing, BorderWidth = 2 },
}, label), onPressed: save)
That control presses in to 98.5% and back, and takes a focus border from the keyboard, on both targets. The rules:
•
When states meet, a press beats a focus and a focus beats a hover, member by member, as the handoff's §10 orders them. Pressable.PressedBackground stays the strongest fill.
•
The focus ring is drawn beside the box's shadows: a raised or glowing control keeps its elevation and its glow while it has focus. On the web the ring used to replace them.
•
A Simulated press or focus draws what a real one does, the ring included.
•
A control with no PressedBackground keeps its own fill while pressed. On the web it went transparent.
•
A transition the surface declares is its own: the pressed fill's fade is a default that a Transition on the box overrides, so a press that scales glides. On the web the fade used to replace it.
•
A disabled control shows no press and no focus, on both targets: not when it was disabled while held, not in a Simulated picture, and not when an enabled control around it is pressed.
Where the targets differ: Photon has no held key, a key activates its control on the way down, so a Pressed diff shows for a held key on the web only. And for a control inside a control, the web presses the outer one too while the inner one is pressed, as CSS's :active rises through the ancestors, where Photon presses only the control the pointer hit.
Focus changed its meaning in this release, from the box's own :focus-visible, which a box never has, to its control's focus: a Focus written before applied nowhere on the web and nowhere on Photon, and now applies.
Since 0.2.0-preview.61
ScrolledStyle = new StyleDiff
Background = theme.Surface,
Elevation = 2, // Flutter's scrolledUnderElevation
BorderColor = theme.Border, // the hairline along the header's bottom edge
Transition = new TransitionSpec(StyleChannels.Colors | StyleChannels.Shadow),
Every member of ScrolledStyle applies, on the web and on Photon, while the surface the header pins to has scrolled past Pinned.ScrolledThreshold (8dp). The diff is laid over Pinned.ScrolledBase: a header draws nothing of its own, and its border is the hairline along its bottom edge, the edge the content scrolls under. Under the header's Transition it glides in and out on both targets.
The surface is the one position: sticky pins to: the nearest ScrollView around the header, or the page when it is in none. A header inside a scrolling panel follows the panel, not the window. On Photon the page itself never scrolls, so a header outside every ScrollView is never scrolled there.
Before this the web wrote four members of seven and a border alone, so an Elevation, a Gradient, a Transform and the Shadows did nothing, and Photon drew none of it.
Since 0.2.0-preview.23
Photon paints in CHILD ORDER: a later sibling is on top, and nothing has to be said. CSS stops working that way the moment boxes acquire stacking contexts, so the web realizer spells out what native gives for free, in the design system's own words, never in CSS's.
Elevation decides what is above, not only how deep the shadow is. It drew a shadow and nothing else, which is half a sentence: a raised surface that anything painted after it covers is not raised. Levels 1–5 are the CONTENT plane, and they are the only numbers an author writes.
Chrome is a plane, not a raised card. Anything pinned (a Pinned header, floating or in flow) sits above every content elevation. It used to sit one step above nothing, which is a number competing with other numbers: give elevation the 1–5 range and a merely raised card would out-stack the header and scroll straight over it.
That is why Pinned has no Layer and never will. A z-index is CSS vocabulary, and an author who has to pick one is being asked to know which numbers everything else chose. Elevation says how high, the realizer decides how that is spelled, and chrome is not on the same scale as content.
One exception to one declaration, one class, and it is not an exception at all once named: a vendor prefix is not a declaration of its own, it is the same one written for another engine. The pair shares a class whose rule names both, standard property last. Alone in a rule, -webkit-backdrop-filter is dropped whole by an engine that only takes the standard name (insertRule leaves an EMPTY rule in Chromium, measured), so the class was computed, hashed, emitted, put on the element, and did nothing. Since 0.2.0-preview.21.
An atomic class is the weakest selector there is. One class is specificity (0,1,0), so every rule in the generated stylesheet that names an element (.eq-spinner rect is (0,1,1)) outranks the class a node carries. That makes the constraint one-directional: the generated stylesheet must never declare a property a node can carry atomically, and a shorthand counts, because it writes every longhand it leaves out.
The spinner is the measured case. Its eight bars each carry their own negative animation-delay, and that phase IS the rotation; the fade beside them was written as animation: eq-spinner-fade 800ms linear infinite, which quietly wrote animation-delay: 0s over all eight. Server-rendered, the ring pulsed in place instead of turning, while the client turned correctly for the wrong reason, an inline style outranking every selector. That rule is longhands now, and a guard asserts nothing but Reduce Motion declares a bar's delay. The same shape as the vendor prefix above: the class was computed, hashed, emitted, put on the element, and something else won.
Since 0.2.0-preview.7
A monospaced run (Text with Mono, a rich run, a CodeBlock) draws in a stack the framework owns, and it lands on an atomic class, which beats a body { font-family } rule, and whose name is a content hash that moves between builds. So there was nowhere to hang your own code face. The hook is a variable:
:root { --eq-font-mono: 'JetBrains Mono', ui-monospace, monospace; }
Every mono run follows it, with the platform stack as the fallback when nothing sets it.
The measurer reads the same variable, and that is not an implementation detail you can ignore if you ship a code editor: CodeEditor places its caret from a measured column advance, so measuring in one face while drawing in another puts the caret beside the character it is on rather than under it.
The full engine (laws, pseudo-states, size classes, the generated stylesheet) is documented in DesignSystem. The abstract vocabulary itself (Box, Row/Column, Grid, Stack, …) is documented in Write-Once Components. Theming = providing an IAppTheme
Themes are typed C# too (colors as light/dark ColorToken pairs, type roles, shape scale, elevation) selected in one line and bridged SSR→client:
builder.Services.AddUI(options =>
options.ScanAssembly(typeof(Program).Assembly)
.UseTheme(PhotonTheme.Instance); // or MaterialTheme.FromSeed(color)
Two implementations ship: PhotonTheme (the default) and MaterialTheme (real Material 3, including dynamic color from a seed). Swap the theme and every component rebrands, server and client, web and native. Runtime light/dark switching is a service (IThemeController); see DesignSystem. A theme also answers what a chart draws data with: IAppTheme.Data is a DataPalette: eight series colours in a fixed order (identity), a one-hue sequential ramp (magnitude), a diverging pair on a neutral midpoint (polarity), four reserved status steps (state) and one de-emphasis gray. It is defaulted to a validated reference palette, so a theme that never thought about charts still serves one correctly; a theme with brand ramps overrides Data and holds the result to the same audit, PaletteAudit (OKLab lightness and chroma, colour-vision separation, contrast), which is what makes a palette safe to change. A ninth series is refused by name, never given a generated hue. The plan behind it is docs/CHARTS-PLAN.md in the repository.
And when NOT to reach for a token
A token INVERTS with the mode, and that is the whole point of it, and it is why almost everything should be one. So the exception is anything whose meaning does not invert.
The case that found this: a specular highlight on a bubble, painted with TextInverse because in light mode that is white and a highlight is white. In dark mode the same token is navy, so the app drew a dark smudge exactly where a reflection belongs. Light is explicit white, never a token: a reflection is light rather than foreground, and light does not become dark when the interface does. The same reasoning covers a shadow that must stay dark, and a brand colour a company does not restyle after sunset.
Since 0.2.0-preview.13
BorderColor = theme.Border,
BorderSides = BorderSides.Top, // a rule above a section
BorderColor = theme.Colors(Variant.Primary).Base,
BorderSides = BorderSides.Start, // an accent bar down a callout
BorderSides.All by default, so every box written before this renders byte for byte the same: a full border still emits the one-declaration shorthand and its single atomic class.
A border is inside the box, and so is the child: the box keeps its child inside its padding and its border on the sides the border is drawn on, and a box that hugs its child counts both in its size, on both targets (Photon laid the child over the border until 0.2.0-preview.61).
A state's border follows the same edges: a Hover with BorderWidth = 2 on a box that draws its bottom edge thickens that edge alone, in the base's colour when the state names none. The web drew it on all four edges, and wrote nothing for a width alone (since 0.2.0-preview.61).
None of these is a Divider. A Divider is a sibling between two things; these belong to the box itself, and the only way to draw one was a Row wrapping a one-dp Box, which is a layout lie about what the design meant.
Start and End rather than Left and Right, matching EdgeInsets: they mirror in a right-to-left reading, and an accent bar that stays on the left when the text flows the other way is on the wrong side of it.
FENCE With a corner radius, a partial border differs slightly between targets at the corner where a present edge meets an absent one: the web mitres it, Photon squares it. At radius 0 (a rule, an accent bar, a table cell, which is what partial borders are for) the two are identical. Use BorderSides.All for a rounded outline.
The low-level web layer (eQuantic.UI.Web) mirrors the DOM 1:1. Every HtmlElement exposes the attributes the browser has:
public abstract class HtmlElement : IComponent {
/// <summary>Raw CSS classes (space-separated).</summary>
public string? ClassName { get; set; }
/// <summary>Inline styles for dynamic values (e.g., coordinates, colors from DB).</summary>
public HtmlStyle? Style { get; set; }
This contract means the web layer does not enforce any CSS framework, it renders HTML attributes. DynamicElement goes further and renders any tag with any attributes. Standard CSS classes from any stylesheet work natively:
new DynamicElement("aside") { ClassName = "my-sidebar" }
The framework ships exactly one styling engine, the typed C# pipeline above. There is no bundled CSS framework and no adapter layer: a page that needs hand-written markup uses the escape hatch (HtmlElement/DynamicElement + ClassName), and any stylesheet the app brings (its own CSS file, a utility framework it builds itself) is referenced like on any web project:
builder.Services.AddUI(options =>
options.ConfigureHtmlShell(shell => shell.AddStylesheet("/css/app.css"));
The framework neither generates nor processes that CSS; it is the app's own build concern.
eQuantic.UI.Primitives
Typed styles, design tokens, the abstract vocabulary: the styling source of truth.
eQuantic.UI.Web
The web realizer + atomic style engine (StyleAtomizer) + generated stylesheet, and the DOM-mirror escape hatch: HtmlElement, ClassName, HtmlStyle, DynamicElement.