Skip to content
Shiny.NET

Blog

Introducing Shiny.AppDeviceBridge — Ship Your App on Your Schedule, Test Device Features Without the Device

Shiny.AppDeviceBridge is a new set of libraries that put a web app, such as Blazor WebAssembly, React, Vue or anything else that builds to static files, inside a .NET MAUI app. The app serves the web app from the device and updates it from your own server. The web app gets the device through typed bridges: GPS, Bluetooth LE, Wi-Fi, notifications, health, the camera, contacts and more. It is available today in beta.

NuGet package Shiny.AppDeviceBridge.Maui

It is built around four problems:

  1. Store release cycles. Every fix waits for App Store and Google Play review, and then for users to update.
  2. Slow inner loops. Rebuilding and redeploying a native app to see a CSS change.
  3. Device features you can’t reproduce at your desk. A Wi-Fi network dropping, a permission the user denied, a GPS walk around the block, a Bluetooth device that shows up three seconds late.
  4. Web apps stop when the app does. A job, a geofence or a push arrives while no page is running.

Putting a web app in a native shell has been done for years: Cordova, Capacitor and, in .NET, a few takes of its own. The idea isn’t the interesting part. Two things underneath it are.

A real HTTP server, not a message channel. The page is served by Shiny.Net.HttpServer — HTTP/1.1, HTTP/2 and HTTP/3, running anywhere .NET runs, including inside .NET MAUI where ASP.NET Core cannot. That brings routing, middleware, DI scopes, authentication, authorization policies, sessions, WebSockets, Server-Sent Events, range requests, CORS, rate limiting and TLS with it. So the web app gets a genuine origin instead of file:// quirks, and a bridge is an ordinary HTTP endpoint: readable in the network tab, callable from curl, guarded by a real policy.

More of the device than we’ve seen anywhere else. Twenty-six bridges ship in the beta — GPS, geofences, motion, Bluetooth LE, Wi-Fi, notifications, push, health, camera, photos, contacts, calendar, speech, jobs, HTTP transfers, network discovery, OBD-II, wearables, app links, desktop tray and more — each one typed end to end in both C# and TypeScript, and each one simulated without a device.

The native app becomes a thin, stable shell: the WebView, the bridges and a public key. Your UI and most of your logic live in the web app, and the web app updates from your server.

builder
.UseMauiApp<App>()
.UseAppDeviceBridge(
bridge => bridge
.Configure(o => o.AppId = "field-app")
.AddAppSupportBridge()
.AddLocationBridges()
.AddBluetoothLEBridge()
.AddWifiBridge(),
webApp =>
{
webApp.UseBaseline(typeof(App).Assembly, "webapp.zip", "1.0.0"); // runs offline on first launch
webApp.UpdateServer = new Uri("https://api.example.com/webapps");
webApp.PublicKey = """
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
""";
}
);
public class App : Application
{
protected override Window CreateWindow(IActivationState? state) => new(new WebAppHostPage());
}

The server is a few lines of ASP.NET Core, and publishing a release is copying a zip into a folder:

builder.Services.AddWebAppReleases(o =>
{
o.SigningKey = builder.Configuration["WebApps:SigningKey"]; // PEM, from your secret store
o.ReleasesDirectory = "/srv/webapps";
});
app.MapWebAppReleases("/webapps");
/srv/webapps/field-app/
app.json { "minimumVersion": "1.2.0" }
1.2.0.zip
1.3.0-beta.1.zip
1.3.0-beta.1.json { "channel": "beta", "minimumHostVersion": "2.1", "platforms": ["ios", "android"] }

At launch the app asks the server whether the installed version is still acceptable:

  • Below minimumVersion: the update is required and installs before the app shows. Use it for a broken release.
  • A newer release: it downloads in the background and applies on the next launch, or when the page calls POST /_bridge/host/apply-update.
  • A release that needs a newer native app: skipped. minimumHostVersion stops a web build that depends on a new bridge from reaching an app that doesn’t have that bridge.
  • Offline, or the server is down: the app serves the newer of the bundled and installed builds. It always starts.

Every release is signed with ECDSA P-256. Before it can be served, the app checks it against the public key compiled into the app, then checks its SHA-256 and size. Old releases are never reinstalled. The app serves the release straight out of its zip from a loopback Shiny.Net.HttpServer, without extracting it.

Updates are optional. Without an UpdateServer, the app serves the zip compiled into it. That’s a complete setup on its own, and gives you the rest of this post: hot reload, typed bridges and the simulator.

Yes, within the rules both stores already publish. The downloaded content is HTML and JavaScript running in the platform’s WebView. Apple’s guideline 2.5.2 and section 3.3.1(B) of the Developer Program License Agreement allow that as long as updates don’t change the app’s primary purpose. Google Play’s policy against downloading executable code excludes JavaScript running in a WebView. The native capabilities (the bridges) ship in the binary and go through review like any other code. Ship a complete baseline, and use minimumHostVersion rather than sending web features to an app that can’t support them. See App Store review.

Every native capability is a bridge: a package on the app side, and HTTP endpoints under /_bridge on the same origin as the page, plus one Server-Sent Events stream for events. Each bridge’s .Client package declares a [BridgeClient] interface, and a source generator implements it. The native bridge serializes the same contracts, so the page and the device always agree on the shape of the data.

builder.Services
.AddWebAppHostClient()
.AddWifiBridgeClient()
.AddGpsBridgeClient();
@inject IWifiBridge Wifi
@inject IGpsBridge Gps
var network = await Wifi.GetCurrentNetworkAsync(); // null when not on Wi-Fi
await using var readings = await Gps.OnReadingAsync(r =>
{
position = r;
return Task.CompletedTask;
});

Not using Blazor? The TypeScript clients in @shinyorg/appdevicebridge are generated from the same declarations:

import { WifiBridge } from "@shinyorg/appdevicebridge";
const network = await new WifiBridge().getCurrentNetwork();

Where a platform has no implementation, the bridge answers 501 and GET /_bridge/host reports it as unsupported, so the page can adapt instead of crashing. Here is the Blazor sample running in the macOS (AppKit) head. Contacts, GPS, geofences and health are greyed out because macOS doesn’t have them:

The Blazor sample inside the macOS app: platform macos, 19 of 26 bridges available, with contacts, geofences, gps and health marked not on this platform

The bridges are guarded. Every route requires AppDeviceBridgePolicies.Bridges, which by default admits only callers on the device itself, plus the WebView’s launch session. Opening a bridge to a remote caller is a policy you write on purpose. See Security.

A page in a WebView only runs while the app is in front. Mobile apps do real work in the background, though: a scheduled sync, a geofence the user just crossed, a push, a notification tap, a download that finished overnight. AppDeviceBridge hands each of those to the web app, whether or not a page is open.

  • A page is open and listening: the call goes to the page, which has 2 seconds to accept it and then posts its result.
  • No page, or it doesn’t accept in time: the call runs in background.js from the web app’s zip, inside an embedded JavaScript engine (Jint).
  • Never both: once the page accepts a call, it owns it.

Register the sources on the bridge builder:

bridge => bridge
.AddWebAppJob("sync", job => job.WithInternet(InternetAccess.Any))
.AddGeofenceBridge()
.AddPushBridge(o => o.DispatchToWebApp = true)
.AddNotificationsBridge()

Handle them in the page, in C# from Blazor or in JavaScript from anything else:

await nativeCalls.HandleAsync("job:sync", AppDeviceBridgeJsonContext.Default.JobRun, MyJson.Default.SyncResult, async job =>
{
await SyncAsync();
return new SyncResult(RanIn: "page");
});

And in background.js at the root of the zip, for when there is no page:

appdevicebridge.on("job:sync", async ({ name }) => {
const token = await (await fetch("/_bridge/settings/secure/token")).json();
const data = await fetch("https://api.example.com/sync", { headers: { Authorization: `Bearer ${token}` } });
await fetch("/_bridge/files/data/content?path=sync.json", { method: "PUT", body: await data.text() });
});
Source Handler
Background job job:{name}
GPS reading delivered in the background gps
Geofence transition geofence
Motion activity delivered in the background motion
Push push.received, push.entry
Notification tapped notification.entry
HTTP transfer finished transfer.completed, transfer.failed

background.js gets appdevicebridge.on, console and fetch. Relative URLs go to the host’s own /_bridge, so a background handler reads and writes the same settings and files the page uses, and can send a notification. It has no DOM and no timers, keeps no state between calls, and gets 25 seconds and 64 MB per call. It ships in the web app’s zip, so your background logic updates over the air with everything else.

A Blazor app writes its background handler in JavaScript: Jint doesn’t run WebAssembly, and a hidden WebView is exactly what iOS suspends. The handler stores its results through the bridge, and the Blazor app reads them when it next opens. The OS still decides when jobs run: at best every 15 minutes on Android, and less predictably on iOS. See Native calls & background.

In a Debug build, set DevServer and the app loads its pages from dotnet watch on your machine. The bridges, settings, files and session stay on the device.

#if DEBUG
webApp.DevServer = new Uri("http://localhost:5288");
#endif
Terminal window
cd MyApp.Web
dotnet watch run --launch-profile device

Start the app from your IDE as usual. Edit a .razor file, save, and the change appears in the running app. The native app isn’t rebuilt, and the page keeps its http://127.0.0.1:5780 origin, so its bridge calls still reach the real hardware.

Before After a save
The sample's heading reads 'Your web app, with a device behind it' The same app, same session, with the heading changed to 'Shipped without an app store review'
Target Dev server Hot reload
Android emulator http://10.0.2.2:5288 (default) yes
iOS simulator, Mac Catalyst, macOS, Windows, Linux http://localhost:5288 (default) yes
Android over USB adb reverse tcp:5288 tcp:5288 yes, with the socket ports reversed too
Any device over Wi-Fi your machine’s LAN address pages only; reload to see changes

If dotnet watch isn’t running when the app starts, the app serves its embedded or installed build as usual. See Hosting.

Hot reload shortens the loop, but you still need a phone that is on the right Wi-Fi, has granted the right permission, and is walking the right route. shiny-bridge-sim removes that dependency.

NuGet package Shiny.AppDeviceBridge.Simulator
Terminal window
dotnet tool install -g Shiny.AppDeviceBridge.Simulator
shiny-bridge-sim --dev-server http://localhost:5288 # or --app ./publish/wwwroot

It’s a terminal app that stands in for the native app. It runs the real bridge server, with the same guard, the same bridge policy and the same event stream. Every device bridge is replaced by a simulated one built from its [BridgeClient] interface, so it answers exactly the routes the typed clients call. You choose the answers.

The simulator's Bridges tab: a tree of every bridge, with the host reporting ios, and quickentry, rpicamera and tray switched off

Open http://127.0.0.1:5299/ in any browser. The page and the bridges share that origin, so the page needs no changes. Here is the same Blazor sample in Chrome on a Mac. It now reports iOS, and has 23 of 26 bridges, because the simulator says so:

The Blazor sample in Chrome, served by the simulator: platform ios, 23 of 26 bridges, contacts, geofences, gps and health all available

Pick a route and set what it returns: a value checked against its contract, a 204 null, or any error the bridges use, such as 501 not_supported or 403 access_denied, with an optional delay. The page’s client gets a BridgeException with that status and code, just as it would from a real device. Test the permission-denied screen, the loading spinner and the “not on Wi-Fi” state without touching a phone.

The route in the simulator What the page gets
The wifi GET current route editor in the simulator, answering a Harbourfront Café network The sample's Wi-Fi page in the browser showing the Harbourfront Café network the simulator returned

You can also fire any event at the page, switch a whole bridge off to see its 501 path, and change the platform the host reports.

A trail is a timed script of those steps. A .gpx file plays as a GPS walk at its recorded pace. Each point is fired as a gps.reading event and becomes what GET gps/current answers, so pages that poll and pages that listen see the same walk. Ctrl+R records what you do in the Bridges tab as a trail you can replay.

Playing a GPX ride The page receiving it
The Trails tab playing the Harbourfront ride, step 10 of 36 The sample's Location page showing a live GPS reading with heading and speed from the trail
{
"name": "Lose Wi-Fi",
"steps": [
{ "delayMs": 0, "event": "wifi.changed", "payload": { "current": null } },
{ "delayMs": 0, "bridge": "wifi", "route": "GET current", "mode": "null" },
{ "delayMs": 5000, "bridge": "wifi", "route": "POST connection", "mode": "error", "status": 409, "code": "conflict" },
{ "delayMs": 2000, "bridge": "ble", "supported": false }
]
}

The Traffic tab shows every request the page made and the response it got, with full headers and bodies. It uses the same recorder as the in-app traffic monitor, which you can open over the web app in a Debug build.

The Traffic tab filtered to /_bridge/app, showing GET info returning an iPhone 17 Pro to a request from Chrome on macOS

Ctrl+S saves a scenario: the platform, the bridges switched off, every route and event you changed, and every trail. Run it without the TUI in a pipeline and point your browser tests at it:

Terminal window
shiny-bridge-sim --scenario offline.scenario.json --trail walk.gpx --play walk --speed 4 --headless

In a Debug build, the default bridge policy admits any caller that isn’t coming through a tunnel. You can point a browser, curl or a script on your laptop at the phone’s LAN address and drive the real GPS, camera or Bluetooth radio. The camera bridge goes further: a page anywhere can open the device’s camera, watch a live MJPEG viewfinder and take photos. The desktop heads (Windows, macOS through AppKit, Linux through GTK4) run the same web app with the bridges those platforms support, on the machine you’re already using.

Android, iOS, Mac Catalyst and Windows, plus the maui-labs macOS (AppKit) and Linux (GTK4) heads. A bridge either works on a platform or answers 501 there. The simulator runs anywhere .NET 10 does.

Package What it does
NuGet package Shiny.AppDeviceBridge.Maui UseAppDeviceBridge(bridge => …): the bridge server, started with the app, and the bridge builder, with UseShiny() and the UI thread done for you
NuGet package Shiny.AppDeviceBridge.WebView UseAppDeviceBridge(bridge => …, webApp => …), WebAppHostPage: the web app in a WebView, over-the-air updates, the dev server proxy
NuGet package Shiny.AppDeviceBridge.Blazor AddWebAppHostClient(): the page-side client for Blazor WebAssembly
NuGet package Shiny.AppDeviceBridge.AspNetCore AddWebAppReleases, MapWebAppReleases: the signed release server
NuGet package Shiny.AppDeviceBridge.Simulator shiny-bridge-sim: the terminal simulator
Shiny.AppDeviceBridge.{Bridge} one package per bridge: AppSupport, Locations, BluetoothLE, Wifi, Notifications, Push, Health, Camera, Photos, Contacts, Calendar, Speech, Discovery, OBD, Desktop and more
@shinyorg/appdevicebridge the TypeScript clients

The full list is on the Getting Started page.

The repository’s samples/ folder has the Blazor sample shown here, running in every MAUI head, plus a release server and a simulator scenario with a GPX ride along Toronto’s harbourfront.

Shiny Controls 1.3 — Word, Excel and PowerPoint in Your App. Free.

Shiny Controls 1.3 is the Office release. A fourth editor — a free-form Notebook — joins the document, the deck and the workbook; all four wear real ribbons; Find works across every one of them; and SlideView learned to present to a room. Alongside that, IMediaService turns the camera from a screen you build into a service you call, TimelineView lands on both hosts, and the motion icon set nearly triples.

Everything below is on both hosts unless the heading says otherwise — native .NET MAUI and real Blazor components, same API, same painter, no WebViews.

  • NuGet package Shiny.Maui.Controls
  • NuGet package Shiny.Blazor.Controls
  • NuGet package Shiny.Maui.Controls.Office
  • NuGet package Shiny.Blazor.Controls.Office

Go press things first: the whole Blazor gallery is live at shinyorg.github.io/controls.


IMediaService — a camera you call, not a screen you build

Section titled “IMediaService — a camera you call, not a screen you build”

MAUI only.

Most apps reaching for a camera do not want a camera screen. They want a result: a photo of a receipt, a barcode, the number off a credit card. MAUI’s own IMediaPicker gets you there by handing the job to the system camera UI — which cannot show a scan reticle, a bounding box, an effect strip, or a single word of your own copy. So every app that needs any of that hand-rolls a camera page.

That page is now the service.

builder
.UseShinyControls()
.UseShinyCamera(media =>
{
media.CompressionQuality = 85;
media.MaxDimension = 2048;
media.OutputFormat = MediaImageFormat.Jpeg;
});
public class DeliveryViewModel(IMediaService media)
{
public async Task CapturePod()
{
var photo = await media.TakePhotoAsync(new PhotoCaptureOptions
{
Title = "Proof of delivery",
Instructions = "Fit the whole label in frame"
});
if (photo is not null)
await photo.SaveAsync(Path.Combine(FileSystem.AppDataDirectory, "pod.jpg"));
}
}

You get camera and gallery permissions, TakePhotoAsync / RecordVideoAsync through a modal built from CameraView, PickPhotoAsync / PickPhotosAsync / PickVideoAsync, and — from the analyzer add-ons — a one-line verb per document type.

var code = await media.ScanBarcodeAsync(); // closes on the first hit
var card = await media.ScanCreditCardAsync();
var licence = await media.ScanDriversLicenseAsync();
var passport = await media.ScanPassportAsync();
var contact = await media.ScanBusinessCardAsync();
await foreach (var scanned in media.ScanBarcodesAsync()) // stays up and streams
this.Codes.Add(scanned.Value);

Every scan comes in two shapes, and the plural one is the real one: the modal opens when enumeration starts and closes when it ends, so the singular overload is literally “take the first, then stop enumerating”. Cancellation, the user tapping ✓, MaxResults and Timeout all end it the same way. Duplicate filtering is on by default — a code sitting in front of the lens is otherwise re-read every time it drifts out of view and back — and the key is chosen per type rather than by generic equality: symbology plus value for a barcode, since the same digits as an EAN-13 and as a QR code are two different scans.

A few decisions worth calling out, because they are the ones you would otherwise hit later:

  • Anything that presents UI asks for its own permissions first, and returns null on a cancel or a refusal rather than throwing. A cancelled camera is an ordinary outcome, not an exception.
  • The modal ships no localizable strings of its own. Close, torch, flip, flash, retake and accept are drawn vector paths, not font glyphs or emoji — so there is nothing on the page to translate, and the only text it shows is the Title and Instructions you supply already localized.
  • A scan modal has no capture button at all. The camera is on and streaming results; a shutter would invite a tap with nothing for a still to be the result of.
  • Compression defaults live at registration. “Our photos are 85% JPEG capped at 2048px” is stated once rather than at twenty call sites, and the per-call options are nullable so unset is distinguishable from deliberately 92. Nothing is re-encoded when nothing was asked for.
  • The service knows nothing about barcodes. It exposes one primitive — ScanAsync<T> — and each analyzer package contributes its typed verb on top. It is public, so an analyzer we ship no verb for is a dozen lines rather than a fork.

→ IMediaService


A free-form Notebook, in the Office packages

Section titled “A free-form Notebook, in the Office packages”

NotebookEditor is the lone canvas; NotebookEditorView wraps it in a ribbon, section tabs and a page list. A OneNote-style page you write anywhere on, draw over, and fill with the same shapes, pictures and rich text as the .docx and .pptx editors.

var notebook = NotebookDocument.Create("Field notebook");
using var opened = await NotebookDocument.OpenAsync("field.shinynote");
<div style="height:640px">
<NotebookEditorView Notebook="notebook" @bind-Tool="tool" />
</div>
<office:NotebookEditorView x:Name="Editor" />

The one structural difference from a slide is that a page has no edges. A slide is a fixed artboard the viewer fits to the window; a page grows to hold whatever is on it — its extent is the minimum unioned with every item’s bounds plus padding — and the canvas scrolls and zooms instead, so there is always blank room past the furthest thing to keep writing into. Everything else is machinery the other three editors already run on: the same shape geometry, the same rich-text layout engine, the same SkiaSharp painter shared verbatim by both hosts, the same transactional undo stack.

MAUI (iOS)

Ribbon, section tabs, page list Shapes and ink on a page The Draw tab
The notebook with its ribbon, the Kick-off and Research section tabs and the page list open on iOS A grid-ruled page carrying shapes and a pen stroke on iOS The Draw tab with the pointer, lasso, hand, pen, highlighter and eraser on iOS

Blazor

NotebookEditorView The Draw tab on a sketch page NotebookEditor, no chrome
The notebook with its ribbon, section tabs and page list on Blazor The Draw tab over a grid-ruled page of shapes, arrows and a pen stroke on Blazor NotebookEditor with no chrome — a ruled page with highlighted text, an ink annotation and a shape on Blazor

Three layers of state, kept apart deliberately. The tool decides what a press starts — Select, Text, Shape, Pen, Highlighter, Eraser, Lasso, Pan. The selection is a set of item ids rather than a single index, because a lasso routinely catches thirty strokes and a picture and they all have to move together. Text editing is a caret inside exactly one item. Escape steps back out one layer at a time — leaves the text, puts the tool down, clears the selection — which is the only affordance that makes a modal canvas safe to hand someone.

Ink is a real model, not a stroke overlay. Pressure is normalised 0..1 where 0.5 means “no idea” — the value a mouse, a finger on a screen with no force sensor and a stylus mid-flick all report — and it multiplies the pen’s nominal width rather than replacing it, so switching device does not change how thick the pen looks. The highlighter paints beneath every other item, because ink over text greys the glyphs even at 40% alpha, which is exactly what a highlighter is not for. The point eraser splits a stroke into separate items where it passes through rather than leaving a hole in the point list. Hit-testing and lasso capture both work against a stroke’s path, not its bounding box — a stroke’s rectangle is mostly empty, so treating it as solid makes one flourish swallow every click in that corner.

.shinynote is a zip holding JSON and the pictures: notebook.json for the notebook, its sections and each page’s settings, pages/{pageId}.json for that page’s items in z-order, pictures as files under media/. Pages are separate entries because a notebook is the one Office-shaped thing here that genuinely grows without bound, and it makes a page recoverable when a neighbour is corrupt. This is also the one editor whose model is the truth rather than a projection of an OOXML package, so there is no byte-identical promise — the equivalent guarantee is that everything survives a save and reopen.

It is also the one Office surface whose page follows the app theme. A document and a deck are pictures of something printed, so tinting the paper misrepresents the file; a notebook page was never printed and has no canonical appearance. Existing ink is never recoloured, though — repainting a user’s strokes is not theming.

→ Notebook


The Spreadsheet, Document Editor, Slide Editor and Image Editor were each a single scrolling strip of two dozen icons separated by anonymous hairlines. They are titled groups on tabs now, with undo and redo in a quick access row where they never move.

Control Tabs
Spreadsheet Home (Clipboard · Font · Alignment · Number · Editing) · Data (Cells · Columns · Functions)
Document Editor Home (Font · Paragraph · Proofing) · Layout (Page Setup · Insert · Zoom) · Shapes
Slide Editor Home (Slide · Font · Paragraph) · Insert · Shapes
Image Editor Home (Tools · Shapes · Image) · View · contextual Drawing / Shape / Text Tools

Blazor

Home tab, in named groups Insert tab Narrow — low-priority groups folded
The Home tab with Clipboard, Font, Paragraph and Editing groups The Insert tab with the Tables and Illustrations groups The same bar in a narrow window with low-priority groups folded into buttons

MAUI (iOS)

Phone width — every group a button A contextual tab
Every group collapsed to a single button on a phone-width window The Format tab appearing once a picture is selected

The Ribbon on its own demo page — the same bar the four Office editors now wear.

The tab strip stays off by default where there is a single tab to show — these are bars a host drops above a surface, not an application’s whole chrome, and a strip carrying one “Home” is noise.

The split is by what a command changes, not by how often it is reached. On the spreadsheet, Home changes how a cell looks and Data changes the shape of the sheet under it — which is what let the structural half finally grow past its ceiling. Delete rows and columns now sit beside the insert pair whose icons they mirror; a Width split button fits columns to their contents and offers four fixed widths behind its chevron, including the sheet’s own default (the only way back once a column has been dragged); hide and unhide columns; and a function library gives SUM, AVERAGE, COUNT, MIN and MAX a button each. All of those existed on SpreadsheetController with nothing on the bar to reach them.

The document editor was tried with four tabs, and Insert, Layout and Review each ended up holding a single group — a click to reach a bar with one button on it. Proofing rides on Home instead, because spelling is something you do while writing rather than a separate pass. The slide editor stops at two, because a slide is a fixed artboard always scaled to fit: there is nothing to pan to or zoom in on, so nothing to fill a third tab with.

Shapes are a tab in both editors, not a dropdown. Twenty shapes behind one button is a panel large enough to cover the document it is about to draw on. Every button is drawn as the shape it inserts, using the same polygon, star and arrow maths the painter uses to lay that shape into the document — hand-drawn icons drift from what actually gets inserted the first time either side is adjusted.

Two ribbon features came out of this and belong to Ribbon itself:

  • Ribbon.SimplifyBelowWidth switches the bar to the dense one-row layout on its own. Group collapsing is the wrong answer at phone width — it folds groups into dropdowns worst-first, which is right when a window is a little too narrow, but on a phone there is room for no group at all and every command ends up behind a dropdown. A collapse the user asked for is never overridden.
  • A ribbon that scrolls now says so. Where collapsing is off, or the collapsed groups still do not fit, the body scrolls — and a scrolling bar looked exactly like one that did not. Both hosts draw a fade on whichever edge still has content past it. The platform scroll indicator is not the answer: on iOS and Android it only appears once a scroll is under way, which is after the moment the user needed to be told.

→ Ribbon · Spreadsheet · Document Editor · Slide Editor · Image Editor


Home ▸ Find carries a box, a 3/12 readout and a previous/next pair on the Word, PowerPoint and Excel toolbars — one OfficeFindBar per host over one IFindController, which all three finders implement. The bar has no idea whether “the next one” is a paragraph below the fold, a shape on slide nine, or a cell three sheets over.

Typing searches as you type and steps onto the first hit at or after the caret, not the top of the content — a find that always restarted at the beginning takes the user away from what they were reading. The arrows wrap, because a “next” that goes quiet at the last hit looks identical to one that has finished the document. A hit is selected rather than merely scrolled to: everything a person does after finding a word operates on the word. Finding changes nothing, so it stays live in a read-only editor.

What each search covers is decided by what its arrows can reach. Word searches paragraphs and not table cells — a document position is a block and an offset, and a table has neither, so counting those hits would promise something “next” could never step to. PowerPoint searches the whole deck but only the shapes a slide itself owns, since a hit inside a layout or master would count the company name once per slide and step the user into something they cannot select. Excel searches cell text as the formula bar shows it, which is the only choice under which searching SUM finds the cells that total something.

Every hit is washed amber and the one you are on is drawn as the selection instead of stacking the two — stacking made the current match a muddy blend and the hardest thing on the page to pick out, which is the opposite of what it is for.

→ Find in Office Documents


A viewer shows a deck to the person holding the device. Presenting mode shows it to a room.

Blazor — SlideView inline
SlideView showing a .pptx slide inline on Blazor, fitted inside a bordered dark surround

SlideView inline. Presenting takes the border off, blacks the surround out and fits the slide edge to edge with the control bar over it.

this.Viewer.StartPresenting(); // MAUI
<SlideView @ref="viewer" Deck="deck" @bind-IsPresenting="presenting" />
@code {
Task PresentAsync() => this.viewer!.StartPresentingAsync(); // Blazor
}

IsPresenting fits the slide edge to edge on black with no border and no margin, drops the app’s chrome, and lays an auto-hiding control bar over it — previous, a counter, next, Notes, Exit — that fades after a few seconds and comes back on a touch or a pointer move. Tapping advances, except in the left quarter of the surface, which goes back. Mode is ignored for the length of the show and restored when it ends, because a thumbnail wall is how you find a slide rather than how you show one, and the inline viewer is left on the slide the show ended on.

Presenting also pins the theme: black surround whatever the app is set to, and the slide’s border dropped. A viewer’s chrome is part of an app, but on a projector any lift at all reads as a grey frame around the deck.

Speaker notes are in the show. The Notes button appears only when some slide in the deck actually has them, and the panel does not fade with the bar — notes are read while you are talking rather than while you are moving the pointer, so putting them on the chrome’s timer would mean wiggling the mouse to finish a sentence.

On MAUI the show is a modal page carrying its own SlideView, not a re-parented viewer: moving a view in the tree rebuilds its platform view, which is a visible stall on a canvas. Its own controller too — a controller owns a viewport, and sharing one would leave the inline viewer laid out for the projector after the show ended, with nothing to resize it back. Only the index crosses back. A modal page is also what makes the platform back gesture work, and the display is kept awake for the duration.

On Blazor the CSS covers the viewport and then the Fullscreen API is asked for on top of it, in that order — requestFullscreen can be refused, and a refusal still has to give the room a full-window deck rather than nothing at all. F5 starts a show and Escape leaves one, which are PowerPoint’s keys. Prefer StartPresentingAsync() over setting the bound parameter: a browser grants fullscreen only inside the gesture that asked for it, and a round trip through a parameter loses that gesture.

→ Presenting mode


Individually small, collectively the difference between a demo and something you would hand a user:

  • Page orientation. Layout ▸ Page carries Portrait and Landscape as two toggles rather than one, because a page is one of two things rather than on or off. Turning the paper swaps the dimensions and writes w:orient — do one without the other and Word either shows the wrong state or re-swaps on open.
  • Page numbering, headers, footers, page breaks and print layout are on the ribbon. All four were already in the controller and reachable only from code. The page number is a menu rather than a button, because a number has a place and a form, and it appends to a header already there rather than replacing it.
  • Page margins are four buttons — Normal, Narrow, Moderate, Wide — rather than one button that opens a sheet of four. Four is few enough to show, and the whole point of a ribbon is that the choices are on it.
  • Zoom, and a fit-width that makes a page readable on a phone. Pinch on touch, ctrl-wheel on the desktop, and a Zoom group stepping 50 – 300%. Fit width sets the zoom so the page exactly spans the window, which on a phone is the difference between a document you can read and one you pan across a line at a time.
  • Cut, copy, paste, insert row and insert column on the spreadsheet — whole rows and columns, not just cell ranges, taking values, formulas and formatting as one undoable step. A marching-ants border marks what is on the clipboard, in its own colour rather than a dashed version of the selection green, since marking a source and moving to a destination is the whole shape of a paste.
  • Both editable surfaces can be panned with a finger. A drag meant “extend the selection”, which is right for a mouse and left touch with no gesture to scroll with — on a phone there was no way to reach a column off the right-hand edge at all. Under touch a tap selects, a drag pans, and the selection is extended by dragging the round handles on its ends. Nothing changes for a mouse. The kind is read off each pointer event rather than decided per platform, because both turn up in one session on an iPad with a trackpad.
  • Spelling suggestions on the keyboard accessory bar (iOS/Android). The red underline was the whole of what a phone user got, since the menu that acts on one hangs off a long press — and a long press is not a gesture anyone performs on a word they were not already suspicious of. Corrections now appear above the keyboard while the caret is inside a misspelling, with Ignore and Add beside them. From the toolbar, Home ▸ Proofing walks the errors in either direction for a complete review loop.
  • Watermarks, on the viewers as well as the editors. Watermark draws a picture behind the content on all six controls, defaulting to a 0.15 wash because the failure people actually hit is one drawn at full strength that makes the page unusable. It is a display watermark — drawn, not written into the file — which is deliberate: Word keeps a VML shape in the header part, Excel has no watermark at all and fakes it with a header image, and PowerPoint expects a picture on the slide master, so persisting means three unrelated mechanisms where drawing means one.
  • Each Office control wears its own colour. Accent paints the ribbon’s header band, tab ink and underline, and defaults to the colour Microsoft’s own application wears — Excel green #107C41, Word blue #185ABD, PowerPoint red #C43E1C. A user reads those colours as “spreadsheet” and “slides” before any label has been looked at. It is the one part of an Office control’s appearance deliberately not taken from the app’s theme; set your own brand colour, or null to leave the bar on the theme.

A vertical rail of markers with arbitrary content beside each one — the wizard’s three-state marker turned on its side and made item-driven. An activity feed, an order’s progress, an audit trail, a changelog.

<shiny:TimelineView ItemsSource="{Binding Events}" ActiveIndex="2">
<shiny:TimelineView.ItemTemplate>
<DataTemplate>
<VerticalStackLayout Spacing="2">
<Label Text="{Binding Title}" FontAttributes="Bold" />
<Label Text="{Binding Detail}" FontSize="13" Opacity="0.75" />
</VerticalStackLayout>
</DataTemplate>
</shiny:TimelineView.ItemTemplate>
</shiny:TimelineView>
<TimelineView TItem="Delivery" ItemsSource="events" ActiveIndex="2">
<ItemTemplate>
<div class="entry-title">@context.Title</div>
<div class="entry-body">@context.Detail</div>
</ItemTemplate>
</TimelineView>

ActiveIndex says how far along it is: nodes before it are complete, the one at it is current and draws a ring, everything after is pending, and the connector fills to match so the rail reads as a progress bar rather than a set of unrelated links. It defaults to -1 — a timeline handed no position should not silently claim its first entry has happened — and AllActive fills every node for a history where everything has already happened.

Rows size to their content, which is what decides how the rail is built: each node is one row whose height comes from whatever the template produced, and the connector stretches to fill it. That is why the rail is built per row rather than drawn as one continuous line behind the stack — a single line would have to be measured against a total height nothing knows until after layout. The marker sits a little below the top of its row so it lines up with the first line of text rather than the middle of the content box. Not virtualized on either host: the deliberate trade for rows of differing height, which is exactly what a recycling list is worst at.

The three templates bind to different things on purpose. ItemTemplate and OppositeTemplate take the item, because content beside a timeline is ordinary content and should not reach through a wrapper to say Item.Title. MarkerTemplate takes a TimelineNode — index, state, whether it caps either end — because everything deciding how a marker is drawn is a property of position and none of it exists on the item.

MAUI (iOS)

Rail left, active at index 2 AllActive RailPosition="Right"
Timestamps opposite the rail, two nodes complete and the third current on iOS Every node filled with AllActive on iOS The rail on the right with the content on the left on iOS

Blazor

Rail left, active at index 2 AllActive RailPosition="Right"
Timestamps opposite the rail, two nodes complete and the third current on Blazor Every node filled with AllActive on Blazor The rail on the right with the content on the left on Blazor

The third entry is long on purpose: its rail segment stretches to the row instead of assuming a fixed height, which is the whole reason the rail is built per row.

→ Timeline


Sixty-nine new icons, each with motion authored for it rather than a preset applied to it: a folder tab that lifts off its crease, a page that turns by squashing about the spine, three raindrops that fall, vanish and reappear above the cloud, a compass needle that settles in progressively smaller swings, a credit card that flips through a horizontal scale of zero.

The additions fill the gaps the original set had — a complete set of arrows and chevrons, the rest of the transport bar (stop, record, skip-back, skip-forward, shuffle, repeat, mute), files and folders, weather, and the round status glyphs — grouped in the docs as Actions, Navigation, Objects, Media, Files, Weather and Indicators. Names stay one flat, case-insensitive namespace, so nothing about lookup or MotionIconLibrary.Names changes and no existing icon was renamed or redrawn.

Directional icons are matched sets: every arrow travels the way it points and pulls its shaft in behind the head, every chevron bounces once in its own direction — so swapping arrow-right for arrow-left in a right-to-left layout gets the mirrored motion for free.

MAUI (iOS)

Triggers & code-driven playback Presets & scrubbing Browsing the set in the sample
Trigger, code-driven and preset demos on MAUI Presets applied to any icon, and progress scrubbing The built-in icon set on MAUI

Blazor

Triggers Driven from code Colour & accent Presets on any icon
Loop, hover, press and appear triggers on Blazor IsPlaying bound to a busy flag on Blazor Colour and accent colour on Blazor Motion presets applied to any icon on Blazor

Every one of those is a single frame of something that only makes sense moving — the playground is the honest version.

→ Motion Icons · the icon set


Terminal window
dotnet add package Shiny.Maui.Controls # .NET MAUI
dotnet add package Shiny.Blazor.Controls # Blazor
dotnet add package Shiny.Maui.Controls.Office # Word, Excel, PowerPoint, Notebook
dotnet add package Shiny.Maui.Controls.Camera # CameraView + IMediaService

The full 1.3 release notes carry everything, including the fixes this post skipped. Then go press things in the playground, and see the controls documentation for the rest.

Shiny HTTP Transfers: Background Transfers, and Knowing What They're Doing

Why is this data still not here? Usually because nothing was carrying it — HttpClient stops the moment the OS suspends your app. Shiny.Net.Http hands the bytes to the platform instead — NSURLSession on iOS, a foreground service on Android, a connectivity-driven managed loop everywhere else, Service Worker Background Sync in the browser — and gives all of them one API. Plus why Azure Blob and S3 get hand-built request signers instead of the vendor SDKs, what the Android foreground service does when the network drops, and how speed, percent and time-remaining are measured consistently on every platform and rendered onto the Lock Screen and the notification shade from one manager.

DocumentDB in Aspire

Which database backs your document store, and how it gets seeded, belong in the AppHost — not compiled into your service. The Aspire integration makes the consuming code one provider-agnostic line with health checks and OpenTelemetry attached, models the admin UI as a resource, and points an entire Orleans silo at the same store.