eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsWrite-once
Photon: The Native GPU Engine
Edit this page
45 min read
Photon is eQuantic.UI's proprietary GPU rendering engine for native targets: a from-scratch, Impeller-inspired renderer that draws the component tree pixel by pixel on the GPU (Metal on macOS/iOS, Vulkan on Android), with no Skia tier and no WebView. It is the second realization target of the write-once component architecture: the same C# components that lower to DOM+CSS on the web are rasterized by Photon natively.
Why proprietary, why no Skia
Owning a native tier means owning the whole rendering stack: direct Metal/Vulkan backends with precompiled shaders, not a wrapper over Skia. That buys:
•
Predictable performance: fixed pipeline-state registry, zero shader compilation at runtime (the jank source Impeller was built to kill). Pipeline caches persist between launches on both backends.
•
A tiny surface: UI rendering needs a handful of primitives, not a general 2D library.
•
One normative model: the same math, testable to the pixel, across CPU reference and every GPU backend.
Architecture at a glance
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
C# components (write-once, eQuantic.UI.Primitives vocabulary)
│ Build(ComponentContext) // pure, token-based, mode-free
▼
eQuantic.UI.Native.Framework // C# flex layout engine (spec A2)
▼
eQuantic.UI.Native.Components // PhotonRealizer: abstract tree → display list
▼
eQuantic.UI.Native.Engine // geometry, color model, Sdf.cs (NORMATIVE), DisplayList
▼
IRenderBackend
├── eQuantic.UI.Native.Engine.Reference // scalar CPU rasterizer (golden ground truth, never shipped)
├── eQuantic.UI.Native.Engine.Metal // Apple GPUs (macOS, iOS)
└── eQuantic.UI.Native.Engine.Vulkan // Android
eQuantic.UI.Native.Shell.MacOS / .iOS / .Android / .Windows // the window/host per platform
The normative core
•
Sdf.cs is a spec, not a library: per-corner rounded-rect signed distance, centered stroke (|d| − w/2), 1-pixel anti-aliasing coverage ramp. Every rasterizer, the CPU reference and the GPU fragment shaders alike, implements exactly these formulas.
•
Color model: sRGB authoring → linear premultiplied blending → sRGB store, mirroring GPU hardware behavior with sRGB render targets.
•
Display lists: flat, heap-free DrawCommand records — nine kinds: Clear, FillRRect, StrokeRRect, ShadowRRect (the analytic §05 shadow), BeginLayer/EndLayer (group opacity), Texture (A8 coverage or RGBA8 images), BackdropBlur (frosted glass) and FillAnnularSector (the ring slice, since 0.2.0-preview.45); solid, two-stop linear and elliptical radial paints; baked 2D transforms and device-space clips.
Golden-image harness
Every scene renders through a backend into a surface, reads back sRGB pixels, and compares against committed PNG goldens (dependency-free codec, EQ_UPDATE_GOLDENS=1 regeneration flow, ±2 tolerance, failure artifacts with amplified diffs). The scene catalog is shared: the Reference backend pins the goldens, the GPU backends run the same catalog for parity, and GPU↔CPU parity holds across the full shared catalog within the golden tolerance, on Metal and on Vulkan.
The GPU backends are driven through slim typed objc_msgSend/P-Invoke bindings: no binding framework, no C shims, zero third-party packages.
Component rendering
The native realizer draws real component chrome (token-resolved fills, inside borders, per-corner radii):
Subsystem
State
Shapes, borders, gradients, transforms, blending, clip
✅ Full, golden-tested
Layout (flex + wrap, grid, stacks, adaptive, truncation)
✅ C# engine
Shadows (elevation §05)
✅ Analytic ShadowCoverage, both backends
Text
✅ REAL. Texture primitive (A8 coverage × tint) + platform ITextRasterizer; macOS = CoreText (system frameworks only, no HarfBuzz/FreeType where the platform has a text engine). One engine measures AND rasters, so line breaks agree by construction. Two faces: the system's proportional one and its monospaced one (TypeStyle.Mono). No glyph is ever clipped: the bitmap is sized from the line's INK box (CTLineGetImageBounds, not the font's declared metrics, since a deep g tail passes beyond them) and reports how far above the line box it had to reach, so the realizer raises the draw by exactly that and the line box still lands where layout put it
Icons
✅ REAL. Pure-C# SVG path parser (full command set, arcs via F.6.5) + CoreGraphics rasterizer, same Texture primitive
Group opacity / transforms
✅ Engine layers
Scroll compositor
✅ Path-keyed offsets, real Pinned pinning (vertical)
Motion
✅ Loop motion, Presence enter/exit with command-snapshot replay, and every style channel glides — see below
Custom drawing
✅ Canvas: the app paints the engine's own shapes in its box, and the pointer arrives in its coordinates
Gestures
✅ Hover pipeline, press with slop-cancel, drag-to-dismiss (follow + glide)
Anchored overlays
✅ Synthetic overlay layers positioned from absolute anchor bounds
Images
✅ REAL. Decoded by the platform (CoreGraphicsImageLoader on the Apple shells) into straight-sRGB RGBA8 textures, drawn through the same Texture primitive as text and icons
A sentence draws run by run
Since 0.2.0-preview.28
A rich paragraph (Text.Spans) used to rasterize as ONE block in the base style, so the bold word, the inline code span, the italic term and the mid-sentence link all dissolved into flat prose, the widest write-once divergence left, since the web has emitted a span per run from the day runs existed. The unit is now a FRAGMENT, a run on a line, because that is the only piece with a rectangle and both halves need one: each fragment rasterizes in its own style (weight, mono, italic, its own size), and a linked fragment's rectangle is a hit region the shell navigates from, so inline links are pressable on Photon, closing the roadmap item that named them.
A paragraph aligns its lines
Text.Align was honoured by the web realizer and IGNORED by Photon, from a real onboarding screen: a two-line centred paragraph rendered flush left, under a title that only looked right because ONE line centres by its container.
It was not a pass-through, because every text service cuts its raster tight to the longest line and there is no box to align inside. Offsetting the finished raster by (box − raster) / 2 centres the BLOCK, and for a paragraph whose longest line already fills its box that is a shift of about zero while the short lines stay left — exactly the case that hurts. Alignment is per LINE. The rasterizer takes it, the three services place each line by it, and the headless placeholder bars place their rectangles by the same arithmetic, which is what makes it assertable with no platform text service in the loop.
Two consequences worth knowing when you use it:
•
A Photon Text takes the width its parent decides, as a block does on the web and as Flutter's does under a tight constraint: a Box with a width, a Row or a Column that stretches its children, the page itself. Its lines align across that width, the longest one included. Where nothing decides one (along a Row, inside a button), the text is its content's width, so the block a line centres in is the paragraph's longest line, and the paragraph as a whole is placed by its container. Centring the block and centring its lines are two different jobs, and a two-line label inside a shape (Mermaid's nodes, for one) wants both.
•
A rich paragraph aligns too, and its pieces move with the line rather than the raster, so a linked run stays pressable where it is drawn. Those are two separate calculations in the code and they go through one function on purpose: a link that moves on screen and not in the hit test is a link that stops working.
Interaction is wired: PhotonHost holds a retained root, SetState invalidates, taps dispatch to hit regions: the box drawn under the point wins, and the target a control keeps beyond its box (≥48dp under a finger, at least 24dp under a pointer, see Density) answers only where no box beside it is drawn, or in front of the box it stands in, so a list of short rows gives each row its own presses; a disabled control swallows. A transformed box takes the pointer where it is drawn, a rotated one in its shape, and a press on a field, a code surface, a sheet or a canvas drawn scaled or turned lands on the caret, the cell or the point under it; a scroll view drawn scaled or turned reveals a focused control and a caret as far as it would untransformed. A text field, a code surface or a sheet takes presses only where it is on screen inside the scroll view showing it. The native Counter runs tap → SetState → rebuild end-to-end in tests. PhotonHost.FocusedPath reports what holds the keyboard, including a field being edited (the caret is that field's own focus indicator), because a platform accessibility bridge needs one honest answer, not the ring's half of it.
Fingers never hover, and hover survives the rebuild
Since 0.2.0-preview.24
Pointer events carry a PointerKind, per EVENT and not per host, because hover-ability belongs to the device in the hand (an iPad gains a trackpad, an Android gains a mouse). A Touch-labelled PointerMove drives drags, pans and selections exactly like a mouse and skips only the hover resolution; a touch PressUp/PointerCancel clears whatever hover a mixed-input session left behind, while a mouse keeps its hover through a click. The macOS shell installs an NSTrackingArea and routes MouseExited to PointerLeave(), so crossing the window edge clears the hover instead of latching it.
Hover identity is the layout PATH, not the node reference, and the press learned this first: a component rebuild replaces every instance, and a hover that only knew the old reference painted exactly one frame. It is also a CHAIN of every region under the pointer (CSS :hover matches every ancestor, and the native realizer answers the same question the web twin answers), which is what lets a hover-opened Anchored stay open while you hover the trigger inside it. Pressed still wins over hover on the box that consumed the swap.
The window learns the machine's language
Since 0.2.0-preview.24
A GUI process launched outside a terminal has no LANG/LC_*, so .NET starts invariant even on a pt-BR machine. The shells now resolve the platform's locale truth and apply it before the first frame: AppleLocale reads NSLocale, where preferredLanguages[0] is the UI culture (resources) and currentLocale the format culture (dates, separators), natively the same pair .NET models as CurrentUICulture/CurrentCulture, and AndroidLocale feeds both statics from Locale.getDefault(). PhotonCultureController owns the statics write (default-thread AND current-thread, so every later thread agrees) and repaints on Apply, the theme controller's shape; registered TryAdd, so an app that forces a culture wins. Observing the OS locale changing at runtime is a per-shell fence.
Layout: the CSS rules the engine owes you
The native engine is judged against the browser, because the web twin renders the same tree there:
•
Fill inherits indeterminacy: on an axis the parent sizes from content, a Fill child has nothing to fill and passes the question through; leftover is not distributed on such an axis.
•
Block semantics on width, and a DECIDED height hands itself down: a box whose width is decided stretches an auto-sized CONTAINER child across it (a div's child div is full-width), while a button, link or input hugs there: they are inline-block, and only a FLEX stretch reaches through them (StretchKind). The height half is below.
Since 0.2.0-preview.28 A box whose height is DECIDED (fixed, filled or clamped) hands it to its single child, on both targets. This is the rule that retired the recurring "toolbar glued to the top of its fixed-height bar" family, where every Row inside a 64dp Box needed a manual Height = Fill to reach the bottom of its own bar.
•
flex-shrink with a min-content floor: a line that does not fit shrinks its items instead of overflowing, and never below each item's min-content (the longest word of a text, the sum of a row's children). The floor matters doubly because the clip guards pixels AND pointer: a clipped press region cannot be pressed.
•
Min/Max reflow: when a clamp changes a box's extent, its child re-measures against the final size.
The shells: Photon in a real window
macOS (eQuantic.UI.Native.Shell.MacOS): an NSWindow hosting a CAMetalLayer, zero third-party packages, just slim typed objc_msgSend AppKit bindings. The Metal backend encodes each frame straight into the layer's drawable (per-pixel-format pipeline cache, BGRA8_sRGB for windows); PhotonHost.RenderScale rasters at backingScaleFactor while layout/input stay in dp (retina). OS mouse/scroll/keyboard events route into the ordinary input pipeline, so press visuals, hover diffs and anchored menus behave exactly like the tests. Real San Francisco text and real pack icons render on screen. Try it: dotnet run --project samples/PhotonDesktop. Two headless modes need no window at all: --Photon:MaxFrames 120 presents that many frames and exits, and --Photon:ScreenshotPath out.png (with --Photon:Mode Dark for the other palette) renders ONE settled frame through the reference backend with the same CoreText metrics. That is the CI screenshot step, and the way a fidelity pass against a design handoff is checked. Calling Run() off the main thread says so in .NET terms instead of dying inside AppKit. Resize the window freely: the host adopts the viewport with all state intact.
Neither headless mode tells you the app worked, and that is worth understanding before you build a check on one. A component that throws is CONTAINED — its subtree is replaced by a small red surface and the app carries on, which is what a shipped app should do. Every automated signal sides with the containment: the frames are presented, the exit code is zero, and the accessibility count goes UP, because the containment surface has text of its own. A consumer had "frames presented and exit 0" written down as the check that catches a black window, and discovered an app whose entire title bar threw on every frame passing all of it.
So the frame summary names what was contained:
[photon] frames presented: 6 — CONTAINED: TitleBar
Names, not a count: a component that throws throws on every frame, so a count reports the frame rate and buries the one fact worth having. It prints on both headless modes, and --Photon:StrictRender true makes the process exit non-zero while anything is contained — off by default, because containment is right in a shipped app and an exit code is not where a user hears about it, and on for a self-test or a CI gate, where the opposite is true.
iOS (eQuantic.UI.Native.Shell.iOS): PhotonApp.Run is the entry point; the same engine presents through Metal.
The app runs under the scene life cycle, which an app built with the iOS 27 SDK cannot launch without, and on Xcode 27 every app is built with that SDK, whichever .NET pack resolves. The SDK declares the scene manifest in the app's Info.plist and the shell's PhotonSceneDelegate owns the window, so the app writes no delegate and no plist key. The screen lives for the process rather than the scene: when the system disconnects a scene in the background and connects a new one, the app comes back on the page it left, with its state. In the background the shell draws nothing, and the first frame back is a fresh one.
Android (eQuantic.UI.Native.Shell.Android): the app provides CreateApp (no Main); presentation goes through Vulkan (the Reference backend is the fallback).
The host lives with the ACTIVITY, not with its surface. Android destroys the surface every time the app leaves the screen (Home, another app in front, the screen turning off), and the shell then releases only what presented to it: the swapchain, the window, the backend. The app comes back on the screen it was on, with its state. A rotation, a dark-mode switch, a keyboard attached or a density change does not recreate the activity either: the generated launcher declares them as its own, and the host follows the new size, palette and scale. The swapchain is rebuilt at the window's new size and declares its images upright, so the compositor turns them to the panel: a phone turned with the app in front shows the same screen, drawn the right way up and filling the window.
The system back
The platform's back closes what is open. Every layer that dismisses already binds Escape (a dismissible Dialog, a BottomSheet, a Drawer, an open menu or picker, a ListDetail's compact detail). PhotonHost.Back() runs that binding, and PhotonHost.CanGoBack says whether there is one. A field inside a layer leaves with it. An edit in progress takes no back, and neither does a focus ring alone, as a focused TextField holds no back in Flutter: on a phone the keyboard has already spent the first back going down, keeping the caret, and a back that then only took the caret or a ring away would read as a swallowed press.
An app that declares predictive back is asked BEFORE the gesture ends: the system draws the home screen under the finger when the app has not registered for back. So the shell registers only while CanGoBack is true, re-reading it after every frame; with nothing open, back is the system's and leaves the app, as in every Android app. The generated launcher activity declares predictive back, so the app writes nothing for it, and Android 14 and later read that declaration. Android 12 and earlier deliver the back KEY instead, and so does Android 13, which reads the declaration only from the application, where .NET for Android has no attribute to write it. The shell routes the key to the same Back(), so one press is never handled twice.
The phone's keyboard
Tapping a TextEntry (a TextInput, a SearchField), a code surface or a sheet on a phone opens the platform's keyboard, and the edit ending closes it. The app writes nothing for it: the field it already has is what the keyboard edits.
A phone's keyboard edits a DOCUMENT rather than sending keys: it reads the text around the caret, replaces the word it autocorrected, marks the word it is suggesting for, takes a swipe or dictation. So the shells hand the host the field's whole new state, a TextEditingValue (the text, the selection and the composition, Flutter's names), through PhotonHost.UpdateEditingValue, and the app hears the new text once, through OnChanged, as typing does. The word being composed is IN the value and drawn underlined, so a search filters as the word is typed. An app that rewrites the value (a mask, a length cap) ends the composition, and the keyboard is told. TextInputSession keeps the two in step: after each frame it says whether the keyboard opens, starts over on another field, closes, or takes a value the app changed, and it never sends the keyboard back an edit the keyboard made. A code surface and a sheet keep their text to themselves and take the keyboard's keys and committed text, as on a desktop, so code is never autocorrected.
The keyboard's area is PhotonHost.ViewInsets, Flutter's MediaQuery.viewInsets. The tree lays out above it, the safe area it covers counts once, and a frame laid out shorter than the last one (the keyboard rising, a rotation, a split screen) scrolls the field being edited into what is left. Where nothing scrolls (a form that does not fit what the keyboard leaves, a phone on its side), the screen is lifted until the field shows, as Android does for a window with nothing to scroll, and comes back down with the keyboard. A field inside a ScrollView is the scroll view's to bring into view, and one the person scrolls under the keyboard stays there, as in Flutter. Put a form in a ScrollView when its top must stay reachable while a field is edited.
The keyboard's action key on a single line (Done) submits the field, as Enter does, and then puts the keyboard away with the edit.
•
Android: the surface view answers the keyboard with an input connection over a mirror of the field, described to it: a field that hides what is typed is not learned or suggested, a single line ends on the keyboard's Done key, and a code surface or a sheet is not a document. The first back hides the keyboard and the field keeps its caret, and a tap on it brings the keyboard back. Back with the keyboard down closes an open layer or leaves the app, as it does from a native field (The system back).
•
iOS: an invisible text view at the caret takes the keyboard, so UIKit runs autocorrection, suggestions, dictation and composition against it as it does for its own fields, and the candidate bar appears at the field.
•
A hardware keyboard attached to a phone or a tablet reaches the host as on a desktop: arrows, Tab, Escape and the app's shortcuts, and a dead key's accent combines with the next letter (´ then a is á), which Android leaves to the editor and the shell does. Its copy, cut and paste go through the system clipboard, the same ITextClipboard a page's own Copy button asks for (Capabilities).
Motion that means what it says
A BoxStyle.Transition used to be a web-only glide: native SNAPPED to each new value, and said so in a fence. It no longer does. Colours, opacity, the transform's components, the elevation shadow and a fixed size all interpolate under the box's own spec — its duration, its delay, its curve — and a channel the author left out of Channels snaps, through the same call.
1
2
3
4
5
6
new Box(new BoxStyle
{
Background = hovered ? theme.SurfaceHover : theme.Surface,
Elevation = hovered ? 3 : 1,
Transition = TransitionSpec.Of(StyleChannels.Colors | StyleChannels.Shadow, Motion.State),
})
Two consequences worth stating, because neither is obvious:
•
The shadow grows rather than swapping. A card lifting from elevation 1 to 3 interpolates the offset, blur, spread and colour; one dropping to 0 FADES, which if (Elevation > 0) could never draw. Level 0 is a real spec — zero geometry, transparent colour — so there is no special case.
•
A gliding size is laid out at the interpolated value, so everything downstream of it follows: the child's wrap, the siblings' share of the row. That is what transition: width does in a browser, and it is why the Size channel lives in layout rather than in paint.
The curves are real now too. Curve was four control points with no evaluator, so the native store stood in a smoothstep for every one of them: Motion.Press produced CSS's cubic-bezier(.2,0,0,1) on the web and something else on Photon — one authored intent, two motions. The solver does what browsers do, so a named curve means the same thing on both targets. The solver, CurveEvaluator, is the host's: on the web a transition is a CSS timing function, so the runtime ships no twin of it, and a component that names it fails the build with EQ2010 (since 0.2.0-preview.61).
Since 0.2.0-preview.46
Drawing your own pixels: Canvas
Everything else in the vocabulary is composed from nodes, which is right for a user interface and wrong for a visualization: a sunburst of a million files, a physics-driven set of bubbles, a chart whose geometry is recomputed from data that never stops. Those are ARITHMETIC over a box, and expressing them as thousands of retained nodes pays a reconciler pass to say what a loop could say directly.
1
2
3
4
5
6
7
Canvas(painter =>
{
foreach (var slice in slices)
painter.FillAnnularSector(cx, cy, slice.Inner, slice.Outer, slice.Start, slice.End, slice.Ink);
},
onPointerMove: p => Highlight(SliceAt(p.AngleFrom(cx, cy), p.DistanceFrom(cx, cy))),
label: "Disk usage by folder")
The pointer arrives in the CANVAS's coordinates, which is what makes the hit test the app's: polar for a sunburst, per-particle for a simulation. The engine hands over the point and does not pretend to know what was drawn there. A press captures, so a drag that leaves the box still belongs to the canvas; a canvas with no handlers registers no region at all, so pure ornament never swallows the press underneath it.
The painter's vocabulary is the ENGINE's — rounded rectangles, circles, annular sectors, strokes — and there are no arbitrary paths, because Photon is an SDF rasterizer and a path engine is a v2 fence. That constraint is what buys a second target for free: the same calls become inline SVG on the web, so a visualization is written once. Degenerate input draws nothing by the engine's own rules (a non-positive outer radius, an end angle at or before the start, an inner radius reaching the outer), and both targets are pinned against those rules rather than against each other.
Since 0.2.0-preview.46
Shipping a macOS app: the .app bundle
Since 0.2.0-preview.45
A desktop head built with eQuantic.UI.Sdk.Native on macOS comes out as an APPLICATION, not a process with a window: after Build, the SDK writes <ApplicationTitle>.app beside the build output — Info.plist from the project (ApplicationTitle, Version, the bundle id from EQuanticBundleId, default com.equantic.<assemblyname>), the icon from the same Assets/AppIcon that feeds the phones, the capability manifest read from the assembly — and signs it ad hoc under that identifier. Opt out with <EQuanticMacAppBundle>false</EQuanticMacAppBundle>.
Signing an app you can actually ship
Ad hoc is right for development and honest about what it is: it proves nothing about origin. A PUBLISHED build says who it is, and the moment it does, macOS applies the hardened runtime — which forbids things by default and does not refuse them politely. Code that needs one of them is not denied, the process is killed.
1
<EQuanticSigningIdentity>Developer ID Application: Your Org (TEAMID)</EQuanticSigningIdentity>
That one property is the whole switch: it replaces the ad-hoc signature and turns the hardened runtime on with it (EQuanticHardenedRuntime overrides that default either way).
What your own code needs, it says in C#, on the builder, beside everything else it declares:
1
2
builder.Entitlements.RequireNetworkClient();
builder.Entitlements.RequireUserSelectedFiles();
What the .NET RUNTIME needs, the SDK declares itself, and this is the part worth reading twice because it is measured rather than assumed: a framework-dependent .NET app under the hardened runtime cannot launch at all. Library validation refuses Microsoft's own libhostfxr.dylib before a line of your code runs — "mapping process and mapped file (non-platform) have different Team IDs" — and shipping the runtime inside the bundle does not help, which is the reasonable guess and the wrong one. So the SDK adds disable-library-validation and allow-jit when hardening is on and the app is not fully AOT, logs what it added, and leaves you declaring only your own needs.
PhotonEntitlements names the keys that matter, each saying WHEN you need it, and Require(key) takes any other — the list is Apple's and it grows. EQ3004 refuses a key built at run time, because entitlements are signed in at build.
If you embed wasmtime, the key you declare is RequireUnsignedExecutableMemory(), not RequireJit(). The names mislead: allow-jit covers pages mapped Apple's way (MAP_JIT), which is what .NET's own JIT uses — and which is why the SDK already puts it in a hardened non-AOT bundle for you. wasmtime writes plain executable memory instead, so that one key is the whole requirement, and declaring RequireJit() beside it is a harmless no-op. What decides is how the engine maps its pages, not that it runs WebAssembly: another WASM runtime may well use MAP_JIT, and then the SDK has already covered it.
Measured with a real certificate, all three rows: JIT alone SIGKILLs at the first use, with "namespace":"CODESIGNING" and "indicator":"Invalid Page" in the crash report; JIT plus unsigned passes; and unsigned ALONE passes too, which is what proved the JIT declaration was never the variable. Under PublishAot the SDK declares nothing for you, so an AOT app works its own recipe out — nobody has measured that one.
Worth knowing because nothing without a signing certificate exercises the hardened runtime at all, so this survives a fully green suite and appears at your first notarized release.
Since 0.2.0-preview.46
Three rules keep the signature honest, each learned from a real app:
•
The bundle is the payload. Contents/MacOS is rebuilt from the build output every time, never accreted — a file the output stopped producing does not linger inside the app.
•
runtimes/ is an allowlist (osx*, unix*). Only RIDs a macOS process can load go in; a package that also ships browser-wasm or linux-x64 native assets (Microsoft.Data.Sqlite does) no longer makes codesign --deep refuse the whole bundle.
•
A signing failure fails the build. It used to be a warning, and the app kept the PREVIOUS signature — an identity that flips between builds is how a Full Disk Access grant silently evaporates, because TCC keys its grants to the signing identity.
The app you ship comes from dotnet publish
Until 0.2.0-preview.47 it did not. The .app was assembled from the BUILD output and publish/ held loose files with no bundle in it, so every publish decision stopped at the bundle's door: trimming, self-contained, single-file, AOT. Measured on this repository's own desktop sample with trimming on — publish 23 MB, bundle 172 MB. The app you would have shipped was seven times the app you asked for, and none of the trimming reached it.
Now the bundle lands INSIDE the publish directory, beside the loose files, which is where MAUI puts its own and therefore where tooling already looks. Nothing to configure: dotnet publish produces it.
1
2
dotnet publish -c Release -r osx-arm64 --self-contained
# → bin/Release/net10.0/osx-arm64/publish/My App.app
Trimming works, and had to be made to. Four defects, every one of them the trimmer reading the same evidence this design relies on and reaching the opposite conclusion: an app never names its shell in code, so the trimmer removed it and the app died at launch saying no shell was referenced. The SDK now roots what the SDK added, annotates the seams the container reaches through, and turns on the configuration binding generator in the assembly that binds — without that last one an app opened its window and ignored every setting, silently. Measured after: 86 MB → 23 MB, zero trim warnings, the same 110 accessibility elements.
Since 0.2.0-preview.47
Saying what the system reads about your app
The Info.plist had a fixed set of keys, so anything else was unsayable. It is C# now, on the same builder as everything else:
1
2
3
4
5
6
7
builder.Bundle
.Copyright("© 2026 Acme")
.Category(AppCategory.Utilities)
.MinimumSystemVersion("13.0")
.Agent() // a menu-bar app: no Dock tile (Apple calls it LSUIElement)
.UrlScheme("acme") // acme://… opens this app
.Key("NSSomeNewAppleKey", "value"); // the escape hatch, still in C#
A declaration WINS over the framework's own value for the same key, and no key is written twice. Category is an enum rather than public.app-category.utilities, because that string is a typo away from being silently ignored and nobody should have to look it up.
One thing the SDK fixes without asking: Apple's version is one to three integers, and a .NET prerelease version is not. 0.2.0-preview.46 went into the plist verbatim and produced a bundle notarization refuses, in a vocabulary that names nothing you wrote. It becomes 0.2.0, and the build says so once.
Since 0.2.0-preview.47
Notarization and a disk image
Apple scanning the signed app and issuing a ticket, without which a downloaded app is refused with "cannot be opened because the developer cannot be verified". It is always the same three steps, so the SDK does them: zip the way notarytool expects, submit and WAIT, then staple the ticket into the bundle so it verifies with no network at all.
1
2
3
<EQuanticSigningIdentity>Developer ID Application: Acme (TEAMID)</EQuanticSigningIdentity>
<EQuanticNotaryProfile>acme-notary</EQuanticNotaryProfile>
<EQuanticPackageFormat>Dmg</EQuanticPackageFormat>
Credentials never live in the project: either a notarytool keychain profile, or EQuanticNotaryAppleId + EQuanticNotaryTeamId + EQuanticNotaryPassword from a CI's secrets — a laptop has a keychain and a build agent does not. Each of them reaches notarytool as an argument of its own, with no shell reading it, and the build does not echo the command line that carries the password (Since 0.2.0-preview.62). A diagnostic or a binary log still records every task's parameters, so the one of a notarized publish is not a file to share. The disk image is built from the STAPLED bundle, so the ticket travels inside it and the app verifies offline the moment it is dragged out.
Not yet proven end to end. The steps run in order and fail loudly on a credential that does not exist, and no bundle has actually been notarized by anyone here — nobody has a Developer ID Application certificate yet, which is what the last line of this needs rather than more code. Said plainly because a page describing a feature nobody has run is a page that should say so.
Since 0.2.0-preview.47
The caret, and what a selection has to show
A selection has two ends and only one of them moves. Which one is not something a person can deduce from the band, so the caret is drawn WITH the selection, not instead of it: the band says which characters are held, the caret says which END you are holding, the one ⇧-arrow will move, the one the next character replaces from.
Two rules go with it, and both are about being visible when it matters:
•
The blink phase starts at the last caret MOVE, not at the epoch. Off a free-running phase, a caret that just arrived somewhere can be in its OFF half for half a second: you press ⇧→ and look at nothing. Every write to the caret restarts the cycle, which is why the host routes them all through one property.
•
While a drag is drawing a selection the caret does not blink at all. The end following your pointer is the one thing you are watching.
On the web the field is a real <input> and the browser owns its caret; what is described here is what Photon paints, and what the editor surface (which both targets paint themselves) does.
The blink is PACED, not pinned
A caret is 2Hz motion, not vsync motion, so an editing caret SCHEDULES its frames instead of holding the render loop hot:
•
RenderFrame leaves NeedsRender false when the caret is the only reason for a next frame, and records when the next blink TRANSITION is due;
•
the shells' idle ticks (the macOS 120Hz timer, the phones' vsync callbacks) ask host.IsFrameDue(nowMs) alongside NeedsRender: one present per half-period, landing on the transition, ~2 a second instead of the display's refresh rate.
Real motion (a spinner, a glide, a transition) still keeps the loop hot, and the schedule exists only for the caret. While a drag draws a selection nothing is scheduled at all: the caret is forced solid and the pointer's own movement drives the frames. Ten simulated seconds of a focused field: 20 presents, every one flipping the blink, asserted in CaretPacingTests, count included.
The web needs none of this: the browser owns the <input> caret, and the editor surface's caret div can blink on the compositor.
Hot reload: dotnet watch run, and the app follows
Run any Photon app under dotnet watch run, edit a Build body, save, and the window redraws with the new code and ALL state intact, within a tick. No SDK command, no configuration: the plumbing ships in eQuantic.UI.Native.Components and every host signs up at construction.
The architecture is hot-reload-shaped by construction:
•
the tree is RE-EXPANDED from the retained root on every frame (Build runs during measure), so a patched method body simply takes effect on the next frame;
•
component state lives in a PATH-keyed store, not in the objects an edit replaces, so nothing has to be migrated. Three clicks of counter state survive a reload because nothing touched them.
PhotonHotReload (the [MetadataUpdateHandler]) supplies the missing piece, the next frame itself, because an idle window presents nothing and a metadata update arrives with no input event attached. It does exactly two things:
1.
wakes every live host through a bool the render loops already poll (macOS within 50ms, the phones on their next vsync), and
2.
bumps a GENERATION the host compares on its own render thread to drop the content caches once (text rasters, icons, images). The keys are content, so most edits would miss them, but a patched rasterizer or an edited icon path is exactly the edit a person makes while tuning visuals. Nothing is mutated from the handler's thread.
Rude edits (new types, changed signatures) are the runtime's to refuse. dotnet watch answers those with a restart, which is correct and needs nothing from the framework.
Mobile: the same registry serves the iOS and Android hosts (their vsync ticks poll the same bool), so hot reload works in the SIMULATOR. A physical device needs dotnet watch to reach the process, which is a deploy-channel question, not a framework one.
Running on a simulator or emulator today
Only the desktop loop is one command today (dotnet run, dotnet watch run); the phones are a hand recipe until Track W's W7 folds it into the SDK as dotnet build -t:RunIos / -t:RunAndroid. What works now, and the traps that each cost a session:
•
The Xcode version check is off by default. .NET for iOS refuses the build (E0191) unless the installed Xcode's MAJOR.MINOR equals the one its SDK pack recommends — a version comparison, not a capability check. Installing the "right" Xcode does not settle it either: WHICH pack resolves is decided by the workload manifest, not by the Xcode you have, so a machine carrying Xcode 27.0 and the 26.0, 26.5 and 27.0 packs still resolves 26.5 and is told to fetch Xcode 26.6. The SDK sets ValidateXcodeVersion=false for the iOS target framework, and only when you have not: put <ValidateXcodeVersion>true</ValidateXcodeVersion> in your csproj to have the comparison back. A toolchain that genuinely cannot build still fails, with the error from the thing that failed.
•
iOS Simulator: xcrun simctl boot <udid>, then dotnet build -f net10.0-ios -r iossimulator-arm64 -c Release (a Debug build installs a .app without assemblies and crashes on launch), xcrun simctl install booted <path>.app, xcrun simctl launch booted <bundle id>. Console.WriteLine goes NOWHERE on iOS — read the unified log (`xcrun simctl spawn booted log show --last 45s --predicate 'processImagePath CONTAINS "<App>"'). Launch arguments never reach Main`; configuration travels as environment (SIMCTL_CHILD_Photon__MaxFrames=90 xcrun simctl launch …). An "AOT module … out of date" crash after rebuilding a shell means stale obj/ and bin/ for the iOS TFM — delete them.
•
Android emulator: start it with -gpu host — with swiftshader_indirect there is no Vulkan, Photon falls back to the software Reference backend, the UI thread pins at 100% and every accessibility query times out. Build -c Debug -p:EmbedAssembliesIntoApk=true (otherwise the APK carries no assemblies), then adb install -r and adb shell am start. Configuration travels through an @(AndroidEnvironment) file (Photon__MaxFrames=90); setprop debug.mono.env does not. What the on-screen keyboard and the shell say to each other is logged after adb shell setprop log.tag.PhotonInput DEBUG, from the next edit on (adb logcat -s PhotonInput). A field that hides what is typed shows only how long its text is there.
Shaders: ONE normative Slang source
Sdf.slang (transliterating Sdf.cs) compiles offline via the pinned slangc toolchain into committed MSL + SPIR-V: the SDF fragment plus the textured fragment (texel Load, nearest by definition, because rasters are device-scale, exact Reference parity). App developers never run the shader toolchain; they consume the committed artifacts.
Performance: the promises get numbers
PerfHarnessTests measures what the definition of done promises ("120 Hz, zero steady-state allocations, budgets met") instead of assuming it. On a dashboard-shaped scene (24 cards + a loop-motion strip), per steady-state frame (M-series baseline): 146 draw commands, realize p50 0.23 ms / p95 0.32 ms against the 8.33 ms budget, and steady-state allocation under 74 KB per frame with frame recycling on.
What keeps the number down:
•
a path-string cache: a path is identity, so the host lends a dictionary that survives frames and steady state stops re-concatenating them;
•
a table of layer paths: each overlay layer's root path is built once and kept across frames, instead of a new string per layer per frame;
•
a reused DisplayListBuilder: Reset() keeps buffer capacity; the shells run one builder per loop;
•
frame recycling (PhotonHost.RecycleFrames, opt-in, default off): RealizeResult carries an explicit ownership story. With recycling on, the host OWNS every frame it replaces, the replaced tree feeds a LayoutNodePool, and the next frame is built from it. The production shells (macOS/iOS/Android) turn it on because nothing retains their frames; tests and tooling that hold RealizeResults leave it off and keep immutable trees.
Ceilings are pinned as regression RATCHETS a margin above the measured numbers (152 KB default profile / 74 KB pooled, 200 commands, 33 ms alarm, time deliberately loose so shared CI never flakes). The GPU half of the frame stays covered by parity goldens, not CI timing.
A screen with layers open has a ruler of its own. The dense scene has no overlay, so its pooled ceiling says nothing about a menu, a sheet and a tooltip open; the ruler is what ONE layer adds, the difference between a scene with 24 layers and one with 32, over the eight between them. The dense part is the same bytes in both, and between 24 and 32 the realizer's per-frame lists keep one capacity, so what remains is a layer's cost and not a list's. The ceiling is 528 bytes a layer, which leaves less headroom than the smallest object .NET allocates: one more object per layer fails. The harness runs alone (PerfHarnessCollection, parallelization off), because a ruler of bytes per frame measured beside the rest of the suite measures some of it too.
IME: composition is the platform's text
While a field holds the caret, the macOS shell hands key events to the PLATFORM's input context (NSTextInputClient: the view conforms, registered at runtime like every other override):
•
A dead key or a CJK method composes over several keystrokes as marked text: held by the host (SetMarkedText), rendered INLINE at the caret with an underline, while the field's VALUE stays untouched. Commit (CommitText) enters the field through the same door every keystroke uses; cancel leaves the field exactly as it was. Composing over a selection replaces it, the same rule typing has. An obscured field composes blind (echoing the composition would echo the secret).
•
Editing keys come BACK from the input context as selectors (doCommandBySelector:) and re-enter through the same OnKey door, re-spelled to DOM names: Backspace, arrows (with Shift/Alt variants), Home/End, Escape. Command/Control chords never enter the context: ⌘A/⌘C/⌘Z belong to the app.
•
firstRectForCharacterRange anchors the candidate window at the CARET (host CaretRect() → y-flip → view → window → screen), so the CJK picker appears under the composition.
•
Proof: ImeCompositionTests at the host (dead-key sequence, cancel, compose-over-selection, caret-riding, ranges, focus-loss drops composition), and the window self-test drives the REAL registered selectors (setMarkedText: → insertText:) against a live field. Type option+e e in any field to see é compose.
•
Scope: code surfaces compose blind. Commit works, but the editor's face renders no inline marked run.
On a phone the composition is not held apart: the keyboard keeps it inside the field's text, and the host only marks it (The phone's keyboard).
Accessibility: the semantics tree
Since 0.2.0-preview.29
Photon draws its own pixels, so the OS sees one opaque view, and without a semantics tree the app is INVISIBLE to a screen reader. The tree is a first-class frame artifact:
•
SemanticsTree.Collect(frame) (shared, target-neutral) derives reading-order semantics from the realized layout, page AND overlay layers both, so dialogs are seen. Roles: StaticText, Button, Link, TextField, CodeField, Slider (an Adjustable), Image (labelled Icon), Checkbox, Switch, GridCell, ProgressIndicator, Group, one for each pressable role, and the tab bar, the radio group, the combobox and the dialogs (both below). A control's inner text is its accessible name (the web's <button>text</button> rule); a labelled Icon announces, an unlabelled one is decoration; TextEntry.Label names a field. Identity is the layout PATH, the same identity presses, focus and scroll use, because the tree is rebuilt every frame and references go stale.
•
Scrolled-out content is included, exactly like FocusStop and deliberately unlike pointer hit regions: linear navigation reaches below the fold; clipping guards the pointer, not the reader.
•
PhotonHost.Semantics() exposes the tree; PhotonHost.ActivatePath(path) runs a control the way a tap does (resolved out of the current frame's regions, refusing disabled); adjustable elements answer increment/decrement actions.
•
macOS bridge (PhotonAccessibility): the content view answers accessibilityChildren with EQAXElements (an NSAccessibilityElement that can be pressed): role and subrole, label, value, enabled, y-flipped accessibilityFrameInParentSpace, the path as accessibilityIdentifier, and accessibilityPerformPress routed through ActivatePath. Elements rebuild per query; the previous batch is released.
•
iOS bridge (PhotonAccessibility): the PhotonView is an accessibility CONTAINER whose elements are built from the tree in reading order: role mapped onto UIAccessibilityTraits, frames converted into screen space, the path as accessibilityIdentifier, and the double tap and the adjust swipe exported as accessibilityActivate / accessibilityIncrement / accessibilityDecrement, routed through ActivatePath and AdjustPath. A check is a button whose VALUE is "1"/"0", UIKit's own contract for a toggle, which is why the state is announced in the user's language without the framework shipping either word. Disclosure rides iOS 17's accessibilityExpandedStatus. Elements are rebuilt when the tree CHANGES rather than per query (VoiceOver asks for the list while walking it), and LayoutChanged is posted only while VoiceOver is running.
•
Android bridge (PhotonAccessibility, an AccessibilityNodeProvider): the PhotonSurfaceView answers for a VIRTUAL hierarchy: one child per semantics node, the role as the platform's class name (android.widget.Button, CheckBox, RadioButton, Switch, SeekBar, EditText, ImageView, TextView), bounds in screen pixels, ACTION_CLICK through ActivatePath, scroll forward/back through AdjustPath, and explore-by-touch answered from dispatchHoverEvent. Disclosure is an ACTION there rather than a flag: offering expand is how a node says it is closed. The view must declare itself IMPORTANT_FOR_ACCESSIBILITY_YES, since it is the only view in the window, and without that the window has no accessible root at all.
•
Two fences, one on each phone: neither platform has a third check state, so a mixed checkbox is announced without one rather than claiming to be off; and Android has no link class, so a link is announced as what it does.
•
Proof: SemanticsTests pin order, naming, dialog visibility, disabled surfacing and the activate action, plus the PARITY GATE SemanticsMatchFocusStops, which keeps the semantics walk and the input walk from ever drifting apart. Each shell's self-test then asks the PLATFORM, not the tree: the macOS window prints its live accessibilityChildren, the iOS controller sends the real Objective-C selectors, and on Android uiautomator dump reads the app from outside through the same service pipeline TalkBack uses. The same screen answers with the same 26 elements on all three.
•
Web parity is native to the web realizer (real <button>/aria-label/placeholder), so nothing is owed there. The tree is target-neutral and every shell now has its bridge.
Each pressable role, in each platform's words
Since 0.2.0-preview.60
A radio, a tab, a menu item, an option and a navigation destination each reach the tree as their own role, with their state where the web puts it, and each bridge says it in the platform's own words. Until this release all five were announced as a button.
PressableRole
macOS (AppKit)
Android class
iOS (UIKit)
State
Radio
AXRadioButton
RadioButton
button, selected when chosen
checked, as aria-checked
Tab
AXRadioButton, subrole AXTabButton
View
button
selected, as aria-selected
MenuItem
AXMenuItem
MenuItem
button
none
Option
AXMenuItem
View
button
selected, as aria-selected
Destination
AXButton
Button
button
current, as aria-current
AppKit's and Android's words follow the W3C Core-AAM for the same ARIA role, which is what Safari and Chrome expose for the web half of the same component, with two departures. An option is AXMenuItem, the word for a choice in macOS's own popups, where Core-AAM's AXStaticText would read it as a paragraph. A destination keeps a button's words on all three, as the web's <button aria-current="page"> does, because a ListItem is a destination only while it is the current row. Android has no class for a single tab, option or menu item, so TalkBack reads the item's words and its state, the way Android's own menus sound. UIKit has a role for none of the five, and says a chosen radio the way its segmented controls do, with the Selected trait, rather than with a toggle's "1" or "0".
Where they are heard: a menu's items, a select's and a time picker's options, a navigation bar's and a rail's destinations, a list's current row, and the tabs and radios of a Tabs, a RadioGroup and a SegmentedControl, each read on its own inside its group (below). No item says yet where it sits in its set (#502).
Tab bars, radio groups, comboboxes and dialogs
Since 0.2.0-preview.60
A tab strip, a radio group, a select's field and an open dialog reach the tree as what they are, with the words the web gives them: tablist, radiogroup, combobox, dialog and alertdialog. Until this release a Tabs and a RadioGroup were one unnamed slider whose tabs and radios were never read (#500), a select's field was a button that expands, and a dialog a plain group with a name (#501).
SemanticRole
macOS (AppKit)
Android class
iOS (UIKit)
Where
TabBar
AXTabGroup
TabWidget
a group
a Tabs strip
RadioGroup
AXRadioGroup
RadioGroup
a group
a RadioGroup, a SegmentedControl
ComboBox
AXPopUpButton
Spinner
button, expanded or collapsed
a Select's field, a TimePicker's
Dialog
AXGroup, subrole AXApplicationDialog
Dialog
a group
an open modal Overlay, a DatePicker's calendar
AlertDialog
AXGroup, subrole AXApplicationAlertDialog
AlertDialog
a group
a Dialog whose action is destructive
•
A tab bar and a radio group are containers. A reader lands on the bar or the group, hears its name when the author gave one (a RadioGroup's label), and then each tab or radio in turn, with whether it is picked or checked; activating one runs the handler a tap runs. The keyboard did not change: the bar is still one Tab stop, and its arrows still move the pick. The group itself takes no adjust gesture on any platform, as macOS's tab groups and Android's radio groups take none.
•
The combobox is the field a list opens from: the pressable a listbox panel hangs from, as on the web, saying whether its list is open. A menu's trigger stays a button. Every combobox the library builds is select-only, so it takes each platform's own drop-down words, NSPopUpButton's AXPopUpButton and Android's Spinner, which TalkBack calls a "drop-down list", rather than the editable combo box's that Core-AAM gives.
•
A dialog is an open modal layer, named by its label; a destructive confirm is an alert dialog. AppKit itself describes the dialog's AXGroup as "group", so the subrole is what marks it a dialog. The layer an open menu, select or tooltip opens on Photon is no dialog and no stop: the web has no layer there. A date picker's calendar is a dialog, as it is on the web.
•
UIKit has no trait for any of the four containers. It says a tab bar and a modal view with a property of a CONTAINER that is not itself an element, which the iOS bridge does not build, so it hears them as a group carrying the name.
Status
The native suite pins the engine's behavior with 91 committed golden images (component pairs light+dark, mid-drag sheet states, texture coverage) alongside the unit and host suites. The window runs the write-once showroom with real text, icons, menus and gestures on macOS, iOS and Android shells. Current scope fence: image decode/upload is not included, so Image renders its placeholder surface.