ImageA bitmap in an explicitly sized slot. The size is a constructor argument and not a hint, because layout can never infer extent from a source it has not decoded, and a slot that waits for the network is a page that reflows in front of the reader.
Image("/images/hero.jpg", 640, 360, label: "The team at the 2026 offsite")
Written with the factory, like the rest of the vocabulary. The same call realizes on the web and on Photon; nothing in it names a target.
source
string
Where the artwork is. A path the target can fetch.
width
float
The slot's width, in density-independent pixels.
height
float
The slot's height.
fit
ImageFit
How the artwork fills the slot. Cover by default.
label
string
What a screen reader says. Empty means decorative, which is the HTML rule and holds on both targets.
Three more are set as properties, because they are the exception rather than the shape:
DarkSource
string?
The artwork for DARK mode, when one image cannot serve both.
CornerRadius
CornerRadii
Per-corner radius; the artwork is clipped to it.
Fit
ImageFit
Also settable here, for a tree built without the factory.
new Image("/brand/wordmark.svg", 180, 40)
DarkSource = "/brand/wordmark-white.svg",
CornerRadius = new CornerRadii(8),
Label = "", // decorative: the name beside it already says it
Cover
Center-crop fill. The default, and what a photograph wants.
Contain
Letterboxed inside the slot, whole source visible.
Stretch
Distorting fill. Reserved for art that tolerates it.
Dark artwork is a PAIR, not a branch
A wordmark drawn in ink for light surfaces and in white for dark ones cannot be one file. Colors solve this with a ColorToken pair; artwork cannot, so the pair lives on the node and the realizer resolves it the same way: no branch in app code, and no mode question asked at paint time. On the web both sources ship and CSS shows one, following the OS preference and the app's own forced mode, which the theme controller stamps as data-theme.
Web: an <img> with the slot's width and height, object-fit from Fit, border-radius from CornerRadius, src from Source and alt from Label.
Photon: the engine draws a SurfaceSubtle placeholder box under the same radius until texture upload lands: the documented degradation pattern, stated here so the shape of the page is the same on both targets even while the pixels are not.
v1 fences, shared by both: nine-slice, the loading and error states, and the decode crossfade join with the asset and animation systems rather than arriving on this node.
Server-side image optimization
eQuantic.UI.Images adds an endpoint: a standalone service that resizes, re-encodes and caches images on demand.
Read this first, because the shape of the feature is not what it looks like. The endpoint is not wired to the Image node. Nothing in the vocabulary asks it for a resized variant, and the node has no quality, srcset or opt-in property, and the page you are reading used to say otherwise, and it described a web-only element that preceded the vocabulary. What ships is the service; pointing something at it is the caller's move today.
builder.Services.AddUI(options =>
options.UseImageOptimization(opts =>
opts.DefaultQuality = 80; // 1-100, default 75
opts.Formats = ["image/webp"]; // preferred output formats
opts.CacheTtlSeconds = 14400; // default 4 hours
opts.MaxSourceSize = 10 * 1024 * 1024; // default 10 MB
app.MapUI(); // maps /_equantic/image
GET /_equantic/image?url=<path>&w=<width>&q=<quality>
url
Required. Must start with / and must not contain .., so it cannot leave the content root.
w
Required. Must be one of AllowedWidths, or the answer is 400 naming the allowed values.
q
Optional, 1–100. Falls back to DefaultQuality.
The response format is negotiated from the request's Accept header against Formats, with a fallback the browser can always read. A miss resizes, converts and writes the result to CacheDirectory; a hit serves the cached bytes with Cache-Control: public, max-age=<CacheTtlSeconds>. A source larger than MaxSourceSize is refused rather than decoded, whoever hands it over: the optimizer and the placeholder generator stop reading at the budget, so a stream a caller opens is held to it as a file under wwwroot is.
What it reads, and what comes back
A source is read when its bytes are a JPEG, PNG, GIF, WebP or BMP, whatever its name says. A .tiff is refused before it is opened, and a file the optimizer cannot read, or one whose header asks for more than 16,383 × 16,383 pixels, is answered 400 with no pixel decoded. The optimizer runs on SkiaSharp, and its natives for Linux, macOS and Windows come with the package, so there is nothing to install and no license key to set.
•
Orientation. The orientation a camera recorded is applied to the pixels, so w is the width as displayed, and ImageOptimizer.GetDimensionsAsync answers the size as displayed.
•
Animation. An animated GIF or WebP is served as it is, with its own content type, as the Next.js optimizer serves one: no encoder here writes frames.
•
The response. It is one of the formats an encoder here writes, so Formats takes image/webp, image/png and image/jpeg, and any other, image/avif included, stops the app at startup. The pixels are sRGB and carry no colour profile, which a browser reads as sRGB.
DefaultQuality
75
Quality when the request does not say.
DeviceSizes
640, 750, 828, 1080, 1200, 1920, 2048, 3840
Widths for full-width images.
ImageSizes
32, 48, 64, 96, 128, 256, 384
Widths for smaller-than-viewport images.
AllowedWidths
derived
DeviceSizes and ImageSizes, deduplicated and sorted. The validation list.
Formats
image/webp
Output formats, best first: image/webp, image/png, image/jpeg.
CacheTtlSeconds
14400
What the response tells the browser.
CacheDirectory
obj/eQuantic/image-cache
Where the encoded results live.
MaxSourceSize
10 MB
Above this a source is refused, read by the endpoint or handed over by a caller.
BlurPlaceholderGenerator turns a stream or a file into a tiny base64 data URL. Resolved from the services UseImageOptimization registers, or injected, it reads no source past the app's MaxSourceSize:
var placeholders = app.Services.GetRequiredService<BlurPlaceholderGenerator>();
var placeholder = await placeholders.GenerateFromFileAsync("wwwroot/images/hero.jpg");
It is a standalone helper for the same reason the endpoint is: the vocabulary has no placeholder property to hand the result to.
•
Design System: tokens, including the ColorToken pair DarkSource mirrors •
Styling: HtmlElement and the web-only escape hatch