Skip to content
Shiny.NET

Controls Releases

BREAKINGFeaturewindows,macos,linux
Desktop: one UseShinyDesktop() call. Shiny.Maui.Controls.Desktop is now registered with a single builder.UseShinyDesktop(cfg => …). Breaking: the six separate extension methods are removed, with no obsolete shims. UseTrayIcon(), UseDesktopKeyboardShortcuts() and plain UseDesktopNotch() become UseShinyDesktop(). UseDesktopQuickEntry() becomes cfg.UseQuickEntryWindow(). UseDesktopNotch(o => …) becomes cfg.ConfigureNotch(o => …). UseShinyDocking() with builder.AddDockPanel<T>(…) becomes cfg.AddDockPanel<T>(…). UseFileDrop(o => …) and UseFileDrop<TDelegate>(o => …) become cfg.UseFileDrop(o => …) and cfg.UseFileDrop<TDelegate>(o => …). Forgetting one of the old calls failed silently: keyboard shortcuts that never fired on macOS, or a dock that loaded nothing. UseShinyDesktop() always registers the parts that do nothing until you use them: the tray icon factory, IGlobalHotKeyService, the keyboard shortcut key sources for macOS, Mac Catalyst and Linux, the docking registry, and the notch window service. The quick entry window and file drop stay opt-in, because registering them changes how the app behaves: the quick entry window claims its hotkey at startup and takes over from the in-app popup, and file drop makes the window accept dropped files. It is safe to call twice, and shared code needs no #if except around ConfigureNotch, which only exists on macOS AppKit, Windows and Linux. Blazor registration is unchanged. See Tray Icon.

Featureios,android,windows,macos
CameraView: live video for calls. VideoEncoder is a new frame analyzer that turns a CameraView’s frames into JPEG or H.264 frames for streaming, and RemoteVideoView is a new view that draws the frames received from the other side. Each EncodedVideoFrame carries its codec, size, rotation, mirroring, key-frame flag and timestamp. Frames are encoded un-rotated and the receiver turns them upright. H.264 is Annex B with the SPS and PPS on every key frame, so decoding can start from any key frame. The encoder scales the long edge to MaxDimension (640) and limits itself to FrameRate (15) in WantsFrame(), so skipped frames cost nothing. Bitrate, JpegQuality, KeyFrameInterval and Codec can all change mid-call, and RequestKeyFrame() forces the next key frame. FrameEncoded is raised on the encoder’s thread. RemoteVideoView.Render is thread-safe, applies Aspect, drops H.264 delta frames until a key frame arrives and raises KeyFrameNeeded to ask for one. H.264 uses VideoToolbox on iOS, Mac Catalyst and macOS and MediaCodec on Android. JPEG works everywhere, including Windows, where H.264 reports unsupported (VideoEncoder.IsSupported / RemoteVideoView.CanDecode). UseShinyCamera() registers the new view’s handler. See Live Video for Calls.

Fixios,android,windows,macos,linux,blazor
ChatView: switching conversations mid-load no longer leaks the old chat’s history. If you changed SessionId while an older-history page was still loading, the late answer was added to the new conversation: the previous chat’s messages showed up at the top of it, and that page’s HasMore replaced the new conversation’s, which could stop history loading for good. The new conversation also couldn’t page until the old request finished. Changing the session now cancels the in-flight page (the token passed to GetMessagesAsync), and anything that comes back after the switch is thrown away, even when the provider ignores the token. See ChatView.

Fixwindows,android
Themed fills and outlines follow a theme change on Windows and Android. Several controls painted a fill or outline with one brush and changed that brush’s colour when the theme changed. Windows and Android never repaint a brush whose colour changes after it’s been drawn, so those parts kept their first colour. The walkthrough callout was the worst case: it stayed in the tooltip’s dark fill after switching to the light surface, so its dark title and text couldn’t be seen. Every brush-typed fill, outline and shadow now gets the theme’s brush token, and a theme change hands it a new brush. Affected: the Tooltip and Walkthrough card (fill, outline and shadow), the TreeView checkbox, the ShinyNavBar bar, Ribbon chevrons, menus and the app button, the ShinyTabBar pill, indicator, centre button and menus, the Expander outline, and the ShinyButton and NotchView shadows (ShinyButton’s never picked up the theme at all). Thanks to @FullDevStudio for the diagnosis (#57). See Theming.

Fixios,android,windows,macos,linux
Outlines that were never drawn. Seventeen borders gave a colour token to a brush-typed Stroke, which MAUI drops without an error, so they had an outline thickness and nothing to draw it with. They now draw: the Font, Font Size and Color picker buttons and their popups, ColorPicker’s preview swatch, DurationPicker, MediaPickerButton’s add button, the Chat composer, FAB menu items, FloatingToolbar, the Scheduler’s default event template, the Ribbon’s checked toggle and the Office find bar. The Word editor’s spelling menu and floating panels were outlined in plain grey whatever the theme; they now use the theme’s outline. See Theming.

Fixios,android,windows,macos,linux
Clearing a colour puts the control back on the theme. Once you set one of a control’s colour properties, setting it back to null (or unbinding it) left the old colour in place, because the explicit colour outranked the theme token the control went back to. So any colour that was ever overridden stopped following the theme, including on a light/dark switch. This is fixed in about 45 controls, including AddressEntry, AutoCompleteEntry, BadgeView, ChatView and its composer, CountryPicker, Expander, Fab, FabMenu, FloatingPanel, Flyout, the keyboard accessory bar and tools, LoadingOverlay, OverlayHost, PasswordStrength, PillView, ProgressBar, ProgressLine, the range pickers, Slider, RangeSlider, SignaturePad, TagEntry, TextEntry and its tools, Tooltip and the TableView cells. The same bug also hit state colours that switch between your colour and the theme’s: a TextEntry’s focused, error and placeholder colours, a ProgressBar’s bar colour, and a Ribbon tool’s checked ring, which never came back once the tool had been turned off and on again. See Theming.

BREAKINGFixios,android,windows,macos,linux
WizardProgressBar, PromptView and the keyboard Done item: a cleared colour follows the theme. These controls bound their own public colour properties to theme tokens, so clearing one left it null: WizardProgressBar then drew its steps with no colour, and PromptView and its orb fell back to a fixed colour instead of the theme’s. The theme colour now sits behind the property, so a cleared value goes back to the theme. Behaviour change: while unset, WizardProgressBar’s six step colours, PromptView’s seven colours, PromptOrbView.AccentColor and KeyboardDismissItem.ToolColor now read null instead of the resolved theme colour. They still draw in the theme’s colours. See Wizard and Quick Entry.

Fixios,android,windows,macos,linux
PillView: changing PillColor keeps the outline and text in step. The outline and text colours worked out from PillColor were left behind when it was cleared. They’re now worked out again on every change. PillTextColor and PillBorderColor set back to null now return to the theme; before, they did nothing. Changing Type while PillColor is set keeps PillColor, which is what was already visible. See PillView.

Fixios,android,windows,macos,linux
TableView cells used on their own follow the theme accent. ButtonCell, CheckboxCell, RadioCell, SimpleCheckCell and SwitchCell only applied the theme’s accent when a TableView hosted them, so a cell placed directly in a layout showed the platform’s default colour until one of its colour properties changed. They now apply it as soon as they’re built. See Cells.

Fixblazor
ShineBorder and BorderBeam: the track’s fallback colour matches the theme. With no theme stylesheet loaded, the track fell back to #c4c6d0 instead of the theme’s outline colour (#BBC7DF). See Border Animations.

Fixmacos
FloorPlan works on macOS (AppKit) on its own. SkiaSharp ships no canvas handler for the AppKit head, so a FloorPlanView there came up blank unless the app also used the Office package, which carried the only replacement. UseShinyFloorPlan() now registers that handler itself, and the package builds for net10.0-macos. See Floor Plan.

Fixmacos,linux
Speech tools find their engine on macOS and Linux. The chat speech-to-text, read-aloud and TextEntry microphone tools looked up the speech engine through a path that carries no services on the AppKit and GTK heads, so on macOS they did nothing even with Shiny.Speech registered. All four tools now use the lookup the prompt’s read-aloud tool already used. When no engine is registered at all, the first tap now logs a warning naming AddSpeechServices() instead of doing nothing silently. See Speech Add-ins.

Fixmacos,linux
Keyboard shortcuts warn when they can’t work. On macOS, Mac Catalyst and Linux, shortcuts need the Desktop package’s UseShinyDesktop(). Without it they never fired, and the only hint was a debug message that Release builds strip. It’s now a warning through the app’s logger. See Keyboard Shortcuts.

Fixwindows,macos,linux
Docking says why it’s empty. A DockHostView with no UseShinyDesktop() registered, or with a layout store that throws on load, came up empty or fell back to its initial layout with nothing in the log. Both now log a warning that names the cause. See Docking.

Fixios,android,windows,macos,linux
Registration calls are safe to repeat. Calling UseShinyControls(), UseShinyDesktop() (with the quick entry window or file drop) or the control-feedback options twice (say, once in a shared module and once in the app) registered their quick entry presenters, screen glow and startup hooks twice, so feedback hooks ran twice. Each is now registered once however many times it’s called.

Fixblazor
ShinyImage: a clear error when AddShinyImages() has no HttpClient. Blazor Server and the Web App template don’t register an HttpClient, so AddShinyImages() (and cfg.UseHttpImageDownloader()) failed on the first image with a bare “No service for type HttpClient”. The error now says what to register, or that you can leave the call out, since ShinyImage loads unauthenticated images through the browser on its own. See ShinyImage.

BREAKINGFeatureios,android,windows,macos,linux,blazor
Office: a document store instead of a dozen file events. DocumentEditorView, SpreadsheetView and SlideEditorView now send Open, Save, Save As, Export, Print and the recent list to an IOfficeDocumentStore. One store serves all three. There’s nothing to wire: with none registered, MAUI keeps files in an app folder (Browse is the file picker, Print opens the PDF in the system viewer) and Blazor uses the browser (real files in Chrome and Edge through the File System Access API, with recent files that survive a reload; upload and download in Safari and Firefox). Register your own with UseOfficeDocumentStore() / AddShinyOfficeDocumentStore() or set DocumentStore. Save writes back over the file the document came from, Save As and Export write new files, and the title bar’s name, location and save state follow along. New on each editor: ShowFileCommands="false" hides the File button, backstage, title-bar Save and AutoSave and the file commands, for an app that runs its own. OpenAsync(stream, fileName) (the view owns what it opens), OpenAsync(file), SaveAsync / SaveAsAsync / ExportAsync / PrintAsync and CurrentFile. Breaking: the editors’ SaveRequested, SaveAsRequested, ExportRequested, PrintRequested, FileRequested, OpenRequested, RecentFileSelected and RecentFiles are removed, as are SpreadsheetFileRequest, SlideFileRequest and their action enums (now OfficeFileAction) and Blazor Word’s DownloadAsync. The Word editor’s File-button event is now FileMenuRequested on both hosts (was MAUI FileRequested, Blazor FileClicked). New in the shared libraries: FileSystemOfficeDocumentStore, DocumentExport for Word (docx/pdf/txt/html), OfficeDocument.MarkClean() and OfficeCommand.IsAvailable. See Files & Document Store.

Featureios,android,windows,macos,linux,blazor
Gamepad: floating sticks can claim half the screen. A new GamepadElement.FloatingRegion sets where a floating stick listens for a thumb: LeftHalf, RightHalf, TopHalf, BottomHalf or Anywhere, measured on the view (or the controller body under Sizing="Uniform"). This is the “touch anywhere on the left to move” stick most mobile games use. The default, Around, keeps the old behaviour (the stick plus FloatingZone on each side). Buttons drawn inside the region still take their own touches. Touches in the region no longer pass through to whatever is under the overlay. It is saved with the layout’s JSON. See Customizing.

Featureios,android,windows,macos,linux,blazor
Every control reads an injectable clock. All the controls that use “now” or “today” (calendars, schedulers, range-picker presets, Gantt, Kanban due dates, chat timestamps, Office date fields, image cache expiry) now read the TimeProvider registered in DI, and fall back to TimeProvider.System when there isn’t one. Before, they called DateTime.Now / UtcNow directly. Register a TimeProvider to pin the date for a demo, a screenshot or a test. The Office models (Workbook, NotebookDocument, DocumentEditorController, SlideEditorController) also have a settable Time property.

Featureios,android,windows,macos,linux,blazor
NotchView: a Dynamic Island that fits whatever you put in it. A new control in the core packages: a black, pill-shaped surface that holds any content and morphs its width, height and corner radius to fit it. There are no compact or expanded states. Swap the content (on Blazor, change ContentKey) and it springs to the new size while the new content fades in. Content that grows or shrinks on its own is followed automatically. The content is laid out once at its final size and clipped while the surface animates, so text doesn’t re-wrap mid-morph. Wrapping text inside content with a fixed width (a message at WidthRequest="280", say) is measured at that width on MAUI, so the notch is tall enough for every line. Island or Notch (square top corners) shapes, automatic corners capped at 28, min/max sizes, and tap and size-changed events. On Blazor it overlays the very top of the screen by default: it opens in the browser’s top layer, flush with the top edge of the viewport, above the page and anything that would otherwise clip or cover it, and Inline="true" lays it out in the page flow instead. See NotchView.

Featuremacos
Desktop: a NotchView in the MacBook camera notch. UseDesktopNotch() and INotchWindowService put a NotchView in the hardware camera notch, in a borderless window above the menu bar and every other app. The empty notch is exactly the size of the cut-out, so it disappears into it, and content grows down out of it. On a display without a notch it floats under the menu bar. macOS AppKit (net10.0-macos) only. See NotchView.

Featurewindows,linux,macos
Desktop: the notch window now works on Windows and Linux. UseDesktopNotch() and INotchWindowService are no longer macOS-only. On Windows and Linux the notch is a borderless window centred at the top of the screen, above every other app and out of the taskbar, that morphs to fit its content, as it does on a Mac without a camera notch. It never takes focus, so clicking it doesn’t pull you out of the app you’re typing in, and PointerEntered / PointerExited still work for expand-on-hover. The window is resized once per frame with the notch’s own ease, because Windows and GTK have no animated window resize. On Windows it’s clipped to the notch’s outline with a window region, since a WinUI window can’t be transparent, so its edges aren’t antialiased. Under a Linux Wayland session the compositor decides where the window goes and it isn’t kept on top. The API now exists in the macOS, Windows and net10.0 (Linux) builds, so widen an #if MACOS guard to #if !ANDROID && !IOS && !MACCATALYST to pick it up. It still doesn’t exist on Android, iOS or Mac Catalyst. See NotchView.

Featureios,android,windows,macos,linux
ShinyImage: the disk cache revalidates instead of re-downloading. When a cached image expires, the next request sends its ETag / Last-Modified, and a 304 Not Modified extends the entry without downloading the image again. If the refresh fails (offline, timeout, 5xx), the expired copy is shown instead of the error artwork; set ImageOptions.ServeStaleOnError = false to fail instead. De-duplication now covers the whole load, including the disk read and the revalidation, not just the download, and closes a race where a load that finished at the wrong moment caused a second download. Cache writes now go to a temp file first, so a concurrent read never sees a half-written entry. Expiry runs on an injectable TimeProvider. A custom IImageDownloader can opt into revalidation through ImageRequest.IfNoneMatch / IfModifiedSince and ImageDownloadResult.NotModified. See Caching & ImageService.

Featureblazor
ShinyImage: one fetch per URL. Every ShinyImage showing the same URL now shares one fetch and one blob. Before, each image made its own fetch and kept its own copy. A late arrival joins the fetch in flight, or gets the finished blob with no request. The blob is released when the last image lets go, and one image leaving never cancels a fetch others are still waiting on. The same applies with an IImageDownloader registered: one download per URL per scope, so two users never share. This also fixes a bug where two images on a page could be given the same request id, so one leaving the screen cancelled the other’s load. See Blazor.

Featureios,android,windows,macos,linux,blazor
MediaPickerButton: locked photos, item spacing and a custom add button. MediaPickerItem.CanRemove = false hides a photo’s ✕, so a photo that’s already saved can stay in the set while the user adds more. The lock survives an edit. ItemSpacing sets the gap between thumbnails in the inline carousel (MAUI defaults to 0, Blazor to 12, as before). AddButtonTemplate (a DataTemplate on MAUI, a RenderFragment on Blazor) replaces the dashed “Add Photo” trigger and still hides at MaxPhotos. On Blazor, Photos is now read as well as written: a list the screen passes in replaces what the picker holds, so saved photos can be handed in by URL. UploadAllAsync skips them, since the browser has no bytes for them. See MediaPickerButton.

Featuremaui
Tray Icon: pills and progress rows. A tray menu item can now show a pill after its label (TrayMenuItem.Pill = new TrayPill("New", TrayTone.Success)), and a menu can hold progress rows (TrayProgressItem, added with .Progress(...)): a label, a bar, a right-hand detail that defaults to the percentage, and an optional caption, in five tones. This is the usage-meter style of menu used by apps like Claude Desktop. Setting Value, Label, Detail, Tone or the caption’s text repaints only that row, from any thread and even while the menu is open, so a timer can drive it. Both are drawn natively on macOS AppKit, MacCatalyst and Windows (owner-drawn rows). Linux shows them as text (Upgrade plan [Pro], Session usage ▰▰▰▰▰▰▱▱▱▱ 62%), because the shell draws app-indicator menus itself and keeps only labels. See Tray Icon.

Featureios,macos
MotionIconView: plays run on Core Animation on Apple platforms. On iOS, Mac Catalyst and macOS (AppKit), a loop or play is now handed to Core Animation instead of redrawing the icon on the UI thread every 16ms, so a looping icon costs the app nothing per frame. Each animated property is sampled once per display frame from the same spec and with the same easing code as before, so icons move exactly as they did. There’s nothing to configure: core’s iOS asset does it, and macOS and Catalyst pick it up from Shiny.Maui.Controls.Desktop. Android, Windows and Linux are unchanged. The resting pose, Progress scrubbing and a negative Speed still use the managed scene, and Progress is not reported while Core Animation runs a play. See Motion Icons.

Featurewindows,macos,linux
Desktop notch window: opening and closing animate the content, not just the window. A SetContentAsync on a showing notch window put the new content in place at once and only then morphed the window. Growing, the new content was there in full before the notch had grown to hold it. Shrinking, the old content vanished on the first frame and an empty black shape shrank on its own. Now the new content fades in as the window morphs to its size. On macOS the old content also goes with the notch: it is frozen into an image that is clipped as the window collapses (or grows) and fades out, so closing collapses into the new size. Windows and Linux get the fade-in. The morph also waits until the new content has been built and laid out. Before, it started at once and ran while the main thread was still building the new content, so for anything heavier than a line of text (a full panel) the time ran out and the window jumped to its new size. Nothing to change in your code; MorphDuration = 0 still swaps instantly. See NotchView.

Fixios,android,windows,macos,linux,blazor
NotchView: content no longer slides while the notch resizes. The content was centred vertically in the surface, so when it grew to taller content the top of the new content (a header, a tab strip) was briefly cut off and then slid back into place, which looked like the notch collapsing and re-expanding. Content is now pinned to the top of the surface, so the top stays still and the bottom is revealed or clipped. In the MacBook camera notch (macOS), the window’s resize now uses the notch’s own gentle overshoot-and-settle curve instead of a plain ease-out. See NotchView.

Fixios,android,windows,macos,linux
ShinyNavigationPage: the bar is drawn once per page switch. Swapping a page’s items into the bar rebuilt it once per item, and every item binding that resolved against the page’s binding context rebuilt it again, so five bound items built 25 buttons per page switch. The page now swaps its items and hands them their binding context in one pass, and the bar draws once. A new binding context or command on an item no longer redraws the bar on its own. See Navigation Page.

Fixios,android,windows,macos,linux
Accordion: open items stay open when ItemsSource changes. Every change to a bound collection rebuilt all the generated expanders, so the item the user had open closed whenever anything was added or removed anywhere in the list, and adding 50 items one by one built 1,275 expanders. Expanders are now reused by data item (matched by Equals) across any collection change, including a Reset, and across a new ItemsSource holding the same items. Only new items get a new expander, and ExpandedIndex follows the open item. Changing a template still rebuilds them all. See Expander.

Fixmacos
TableView: switches in cells that can’t be selected respond to clicks on macOS. Every cell carried a tap recognizer, even with IsSelectable="False". On macOS (AppKit) that recognizer took the click from the control inside the cell and then ignored it, so clicking a SwitchCell’s switch, or a slider or button in a CustomCell, did nothing. A cell that can’t be selected now has no tap recognizer, and adds or removes it when IsSelectable changes. Selectable cells are unchanged. See Cells.

Fixios,android,windows,macos,linux
TableView: filling an ItemsSource one item at a time builds each row once. A TableSection’s ItemsSource + ItemTemplate rebuilt every cell on each collection change, so adding items one by one built n(n+1)/2 cells (6,670 for 115 items). TableView.ItemsSource did the same with whole sections, and redrew the table on every change. The rebuild now waits for the end of the current dispatcher step, so any number of changes made together cost one rebuild and one redraw. See Sections.

Fixandroid
BadgeView: the badge is no longer cut off on Android. The badge hangs off the corner of what it wraps, outside the BadgeView’s own bounds, and Android clips each view to its own bounds unless the parent says otherwise. Inside a Border (or any other parent that isn’t a MAUI layout) the bubble was cut off with straight edges at the top and right. The BadgeView now stops its parent clipping it, so it draws whole wherever there’s room for it. The navigation bar’s item buttons were also clipping their badges to a round shape on every platform; they’re now plain rectangles, which look the same since they paint nothing. Thanks to @FullDevStudio for tracking this down. See Badge.

Fixios,android,windows,macos,linux
ShinyFlyout: correct nesting with the tab bar and a docked ProgressLine. When ShinyTabBarBehavior or a docked ProgressLine had already set up its page overlay before ShinyFlyout installed, the flyout wrapped the overlay instead of going inside it. The tab bar then sat inside the flyout’s content, and the next overlay request wrapped the page a second time, rebuilding its native views. The flyout now goes inside an existing overlay, in the content’s place, whichever installs first. See Flyout.

Fixios,android,windows,macos,linux,blazor
Memory leak fixes in the newer controls. An audit of the recently added controls found these, and each is now fixed:

  • AvatarGroup and Marquee (MAUI) subscribed strongly to their ItemsSource, so a view model’s collection that outlived the page kept every visited page alive. Both now subscribe weakly, as CoverFlowView already did.
  • VideoText (MAUI): on Windows and Linux the fallback marquee animation never stopped, and kept its page alive. It now stops on unload. On the masked platforms the player now pauses and lets go of its output when the control unloads, and picks up again where it stopped on load. Before, it kept decoding and kept its page alive until the handler disconnected. Native glyph images, the Android xfermode and the Blazor <video> source are released.
  • Android TapTarget (ShinyButton, ChipView and other tap targets): every change to a Description or Hint nested two more accessibility delegates. Each one held the handler, and TalkBack slowed down over time. There is now always exactly one, and it is removed on disconnect.
  • ChatView (MAUI): automation-id tracking kept every chat bubble the chat had ever built. Bubbles now release it when they unload.
  • Range pickers (MAUI): a popup left open when its page was popped stayed in a static back-button list and kept the page alive.
  • Confetti (MAUI): a burst fired at a page that was never laid out kept a 60 fps timer running for good. It now waits for layout and is cleared on unload.
  • Office (Blazor): in Safari and Firefox every Save kept another full copy of the document in memory. Stale file handles in IndexedDB are now pruned, and a cancelled Browse no longer leaves a hidden input behind. FileSystemOfficeDocumentStore closes the file if a cancelled open fails partway.
  • ShinyImage (Blazor): a shared download that every image had given up on is now cancelled and forgotten.
  • Desktop: the Windows tray no longer leaks a GDI+ font on every pill repaint. The macOS tray pill images are disposed. Replaced macOS notch pages are disconnected, and the notch click monitor is removed when its window closes.
  • Smaller fixes: the CoverFlow template pool is capped, and CoverFlow no longer animates while unloaded. The NoiseEffect paths are disposed. The Blazor colour cache in AnimatedBackground is capped. CoverFlow, NotchView and VideoText now clean up on Blazor even when the JS dispose call throws.

Fixios,android,windows,macos,linux
Keyboard shortcuts no longer keep pages alive. A shortcut scope was registered in the static engine when its element was built and never removed, so every page that ever had a shortcut (and every DialogView, through its Escape shortcut) stayed in memory after a Shell switch or a pop. The scope is now registered only while the element is in a window, and is dropped when the element leaves it. A page that comes back (switched to again or pushed again) builds a fresh scope from its collection, so its shortcuts, precedence and IsModal still work. Thanks to @FullDevStudio (#44). See Keyboard Shortcuts.

Fixios,android,windows,macos,linux
MotionIconView: a finite play stays finite when the view is unloaded. A Play() or Appear that was still running when the view left the tree came back looping forever, because Loaded read the leftover IsPlaying as a bound busy flag. ShinyTabBar hit this on every tab change, so its icons kept animating on every frame. A play the view started itself (Play, Loop, Press, Hover, Appear) now ends at rest on unload. Only an IsPlaying set by the app (local value, binding or style) resumes on the next Loaded. ShinyTabBar replays the selected tab’s icon once it lands on the new page, so the tap still animates. Thanks to @FullDevStudio (#45). See Motion Icons.

Fixios,android,windows,macos,linux
ChipGroup keeps its selection while ItemsSource is null. When the group was re-parented (for example when PageOverlay moved the page content into its overlay root), ItemsSource was briefly null and the selection was pruned against an empty list. The resulting null was written back through a two-way SelectedItem binding, so the view model lost its selection. A null source now keeps the selection and checks it against the next items, as the Blazor ChipGroup already does. Thanks to @FullDevStudio (#47). See ChipGroup.

Fixblazor
ButtonGroup: nested groups render as a cluster on Blazor WebAssembly. A group of groups never got its shiny-btn-group--cluster class on WASM, because the repaint ran before the first render had finished and was dropped, so the inner groups rendered as one merged group. Fixed. A cluster now also wraps its groups onto a second row on a narrow screen, and each group stays whole. See ButtonGroup.

Fixblazor
KanbanView: touch dragging reworked. On touch, a card is picked up by press-and-hold (about 350 ms): the card lifts slightly and, where the device supports it, gives a short vibration. A finger that moves first scrolls the board as normal. Once a card is held, the drag owns the gesture, so the browser no longer scrolls under it or cancels it. Edge auto-scroll now keeps going while the pointer rests near an edge (before, the pointer had to keep moving), runs faster the deeper the pointer is in the edge zone, and stops at the board’s real extent; before, the carried card could grow the scroll area and the board ran away. Dragging past the board’s edge now targets the edge lane. Mouse and pen are unchanged (a drag starts after 5px). See Kanban.

Fixblazor
Phone-width fixes across the Blazor controls. Each of these controls now fits a 360px screen:

  • ShinyToolbar: when the bar is too narrow, the title/start region gives up space first and a long title ends in an ellipsis, so EndContent and the actions are never pushed off-screen. See Toolbar.
  • BarcodeView / QRCodeView: the code is capped at max-width: 100% and, with no CssHeight, keeps its aspect ratio (height: auto), so a 400px barcode scales down inside a phone instead of overflowing. An explicit CssWidth/CssHeight still wins. See Barcodes.
  • MarkdownEditor: in split mode (IsSplit), the preview wraps under the source when each pane would be narrower than 260px. See Markdown.
  • MediaElement: on a player 420px wide or less, the transport bar hides the volume slider (the mute button stays; iOS ignores page volume anyway) and tightens its buttons to 30px. Below 260px the time labels hide too, so the seek bar keeps its room. See MediaElement.
  • Office status bar (Document Editor, Slide Editor, Spreadsheet, Notebook): segments that don’t fit now drop whole from the end instead of being cut off mid-word, and a single over-long segment ends in an ellipsis. See Office Shell.
  • DockHost: the left and right rails are each capped at 30% of the host’s width, so on a phone two 230px rails no longer cover the document area or lie on top of each other’s tabs and resize strips. See Docking.
  • GanttView: the task pane is capped at 45% of the control’s width (min(TaskPaneWidth px, 45%)), so the timeline is always visible. Before, a 385px pane on a 360px phone hid the chart entirely. See Gantt.
  • FloorPlanView: FitOnLoad now refits the plan whenever the canvas changes size, until the user first presses, wheels or zooms. On a phone, the first frame was painted before the page had settled, so the plan stayed a thumbnail in the corner of a much larger canvas. See Floor Plan.
  • BarcodeView / QRCodeView: the code’s box is exactly as tall as the code. The inline SVG left a few pixels of text-baseline gap under it, so a 294px QR code came out 294×300.
  • Ribbon: resize measuring waits for the next animation frame, so phone-width pages no longer log “ResizeObserver loop completed with undelivered notifications”. See Ribbon.

Fixblazor
No more double borders or double scrollbars in the Blazor controls. Each of these drew a line on top of its own frame, or scrolled inside the page for no reason:

  • SchedulerCalendarView: the last column and the last week no longer draw a border against the calendar’s outline, which made the right and bottom edges twice as thick as the grid lines. See Scheduler.
  • SchedulerAgendaView: the same for the last day column and, scrolled to the end, the last hour. See Scheduler.
  • SchedulerCalendarListView: day headers draw only their bottom line. Their top line doubled the list’s top border and the previous day’s last row. See Scheduler.
  • DataGrid: with Bordered, the last column (and a column frozen to the end) no longer draws a right line against the outline. With Outlined or a pager, the last row no longer draws a bottom line against the outline or the pager’s rule. See DataGrid.
  • FontPicker / FontSizePicker: the inline pickers size to their list instead of capping at 320px and scrolling inside the page. The FontPickerButton / FontSizePickerButton dropdowns keep the cap. To cap an inline picker yourself, set --shiny-fontpicker-max-height / --shiny-fontsizepicker-max-height. See Font Picker.
  • MarkdownView: a long unbroken word, such as a namespace in a heading, now wraps instead of turning the view into a sideways scroller that moved only a few pixels. See Markdown.

Fixmaui
No more nested scrollers or double borders in the MAUI controls. Each of these either scrolled inside a page that already scrolls, or drew two lines where there should be one:

  • AutoCompleteEntry: the suggestion list now floats in the page’s overlay layer, directly under the field at the field’s width (or above it when there is more room above), and follows the field as the page scrolls. It used to be a ScrollView laid out inside the control, so on a scrolling form it was a scroller inside the page’s scroller and it pushed the rest of the form down when it opened. The floating card’s AutomationId is the control’s plus _DropDown. See AutoComplete.
  • MarkdownView: tables draw one rounded outer border and single 1px dividers between rows and columns. Before, every cell had its own border, so inner lines were doubled and the outer edge was drawn twice with two different corner radii. See Markdown.
  • TimeRangePicker: the popup card no longer wraps the two time columns in a scroller of its own. Only the columns scroll. On a short or landscape screen they shrink to fit (down to 96pt), so the Apply/Cancel footer stays on screen. Before, the card and the columns both scrolled. The date pickers’ card still scrolls as a whole. See Range Pickers.
  • FontPicker / FontSizePicker: the docs now show how to place the inline pickers on MAUI. Either give the picker a * row on a page that does not scroll, or leave it unconstrained in a scrolling page, where it measures to its whole list. Never give it a fixed HeightRequest inside a ScrollView. See Font Picker.
  • FloatingPanel: the guidance for a list in a panel is now explicit. Use IsContentScrollEnabled="False" with the list in a * row, so the list virtualizes and is the only thing that scrolls. See FloatingPanel.
  • Sample app: fixed the demo pages that put a scroller inside a scroller: Font Picker, ShinyImage (the photo grid is now the page’s scroller and the other demos are its header), the Sheet demo’s country selector, and the Quick Entry event log. The Flyout demo now uses PushMode="Resize", because a resting rail with the default Shift slid the detail 64pt off the right edge. The Flyout and FAB pages no longer scroll by a few pixels for nothing.

Fixblazor
Safari and iPhone fixes.

  • Missing icons in ShinyToolbar, Fab, FabMenu, ShinyTabBar, MotionIcon, the Ribbon’s collapse button and the Office ruler. The stylesheet rule that sizes an icon never reached icons passed in as SVG markup, because scoped CSS doesn’t apply to markup inserted as a string. Chromium sized the SVG from its box anyway; Safari drew it zero pixels wide, so on an iPhone a toolbar showed only the dropdown carets. Fixed. See Toolbar, Fab, Motion Icons.
  • ShinyToolbar: Escape closes an open menu in Safari. Escape is handled inside the bar, and Safari doesn’t focus a button when it’s clicked, so Escape went to the page and did nothing. Opening a menu now moves focus to its first entry whenever focus isn’t already in the bar, as the ARIA menu-button pattern asks. Measuring after a resize also waits a frame now, so it no longer re-renders inside the resize callback.
  • Office editors (Document Editor, Slide Editor, Spreadsheet, Notebook): the shell now reports its width a frame after a resize instead of inside the resize callback. On WebAssembly the re-render ran inside that callback, and Safari logged “ResizeObserver loop completed with undelivered notifications”. See Office Shell.

Choreblazor
The Blazor UI tests now run on phones too. ./uitest blazor-mobile runs the suite in WebKit with iPhone 13 emulation, and ./uitest blazor-android runs it in Chromium at the 360px Galaxy S8 size. A phone-fit sweep opens every gallery page at 360px and fails on sideways scrolling, elements off-screen or text that is cut off. The drag-driven controls also have gesture tests that use real touch input (CDP touch events).

Fixios,android,windows,macos,linux
Dialogs called from a background thread now show instead of hanging. Calling IDialogService from a thread-pool thread (after ConfigureAwait(false), or inside Task.Run) never showed the dialog, and the awaited task never completed. The overlay was built on the calling thread, the platform rejected the view, and the exception was lost in a queue loop nothing awaited. The service now switches to the app’s dispatcher before it touches the page. A dialog that fails to show now completes its caller’s task with the exception, and the dialogs queued behind it still show. See Dialogs.

Fixios,android,windows,macos,linux
BadgeView keeps its bubble when it is shown again during its hide animation. Clearing Text and setting it again straight away (a count that is reset while it is recalculated) left the badge with its text but no bubble. Showing the badge cancelled the hide animation, but the cancelled animation still ran its last step and hid the bubble that had just been shown. Each show or hide now only applies its end state if it is still the latest one. See Badge.

Fixios,android,windows,macos,linux
ShinyButton leaves a bound SemanticProperties.Description alone. When the button’s text arrived before a bound description had its value (both bound in a DataTemplate, text first), the button wrote its text as the description. That local value displaced the binding, so a screen reader kept reading the button text (an icon glyph, say) instead of the bound description. A description that is set, styled or bound now belongs to the caller, checked with IsSet as MAUI’s own SemanticProperties does. See Button.

Featureios,android,windows,macos,linux
Every control names its parts after its AutomationId. Set AutomationId on a Shiny control and everything inside it that a UI test (Appium, WinAppDriver, MAUI DevFlow) or an accessibility tool needs to reach is named after it, as <id>_<Part>. That covers the inner entry, the buttons, the generated items, the popups and the value labels. A few examples: TextEntry → <id>_Entry; ChipGroup → <id>_Chip_0; cells → <id>_Title / <id>_Value; Wizard → <id>_Next / <id>_Back / <id>_Cancel; DataGrid → <id>_Row_3, <id>_Cell_3_1, <id>_Header_2; calendar days → <id>_Day_20261019; Kanban cards → <id>_Card_<card id>. Popups and dropdowns are named too, because they render in the overlay layer, outside the control. Toasts and dialogs take an id of their own: ToastConfig.AutomationId, and configure: c => c.AutomationId = "..." on every IDialogService call. ProgressLineConfig and QuickEntryOptions take one as well. Every suffix is a public *AutomationIdSuffix constant on its control, and each control’s page lists its ids. The parts follow the control’s id when it changes, so binding it in a template is safe. A control without an id names nothing, except where a fixed id already shipped (WizardNextButton, RibbonCollapseToggle, tab-center, nav-back, ShinyTreeNodeCheckBox, toolbar-item-…, ImageEditorTool…), which stays as the fallback so existing tests keep working. Behaviour change: a ButtonGroup segment, FabMenu item or TableView cell that you declare without an id is now given one, and MAUI’s AutomationId can only be set once, so assigning it from code after the child has been added throws. Set it before adding the child; XAML always does. MAUI only; the Blazor controls are unchanged.

Featureios,android,windows
ShinyButton is a real button to UI Automation, Narrator, TalkBack and VoiceOver. It is drawn from a ContentView and a tap gesture, which a finger can use but nothing else can see. On Windows it now has a button automation peer with the Invoke pattern, so Narrator, WinAppDriver and Appium can press it, and it is in the tab order with Enter and Space pressing it. On Android TalkBack announces it as a button and double-tap presses it. On iOS VoiceOver gets the button trait. Every route runs the same code as a tap, so a disabled button ignores them all. Fab, FabMenuItem, ChipGroup chips and the TextEntry / keyboard-accessory tools get the same treatment. See Button.

Fixios,android,windows,macos,linux
Expander no longer throws when a bound AutomationId changes. The expander copied its id onto its header row (<id>_Header) through the AutomationId setter, which MAUI allows only once. A bound id changes every time a BindableLayout or ItemsSource rebuild re-binds the row, and that change threw “AutomationId may only be set one time”. The header now follows the id through SetValue, the same way a binding writes it, and a test scan now keeps the setter out of every control. See Expander.

FlyoutView no longer crashes WinUI with “Layout cycle detected”. The view picked up the host width from both the measure and the arrange pass, and WinUI hands those widths a fraction of a pixel apart (894.7 and 895.3). Every pass then looked like a resize, which re-targeted the panels and invalidated the measure again, until WinUI gave up and killed the app. This hit any FlyoutPanel with CollapseBelow or CompactWidth near its threshold. Only the first measure takes the width now (so a flyout that opens expanded still doesn’t flash), and the arrange pass owns it after that. A width change found during arrange is applied once the pass is over, rather than invalidating the pass that is still running. See Flyout.

Featureios,android,windows,macos,linux,blazor
New: CoverFlowView, an album-art Cover Flow carousel. The current item faces you in the centre, and the items on each side tilt toward it (SideAngle, 60° by default), shrink (SideScale) and stack up (CenterSpacing, ItemSpacing), like records in a crate. Drag or flick to browse: the position follows your finger, a flick carries on with momentum and settles on a whole item, and without Loop the ends stretch and spring back. Tapping a side item brings it to the centre, and tapping the centre item raises ItemSelected. ShowReflection adds a mirrored, fading copy under each item, CaptionTemplate shows details of the centre item, and CurrentPosition is two-way. Only VisibleItems items on each side are created, so long lists stay cheap. On MAUI it uses plain cross-platform transforms with no native handler, with Next()/Previous()/GoTo() and PositionChanged/ItemSelected commands. On Blazor, CoverFlowView<TItem> drives CSS 3D transforms from a small script, and adds keyboard navigation, trackpad scrolling, Perspective and reduced-motion support. It ships in the core packages. See CoverFlowView.

Featureios,android,windows,macos,linux,blazor
New: AnimatedBackground, an animated effect behind any content. It is a container, used like a MAUI Border or an HTML div: the content sits on top, the effect is drawn behind it, and taps and clicks go straight through. One Effect property picks one of twelve effects, each its own typed options object: AuroraEffect, ParticlesEffect (a constellation that the pointer can repel or attract), SnowEffect, RainEffect, MeteorsEffect, DotPatternEffect (a dot grid under a spotlight that follows the pointer or wanders), NoiseEffect (film grain), StarfieldEffect (with a Warp mode), FirefliesEffect, BubblesEffect, WavesEffect and MatrixEffect. Density is per 100×100 area, so a phone and a desktop look equally full, and every effect has a Speed multiplier. The host has CornerRadius, Background, IsRunning and RespectReducedMotion, which holds a still frame rather than going blank. On MAUI the effect properties are bindable and change live, colour lists are comma-separated in XAML, and the effect is drawn on a GraphicsView ticked by the window’s shared frame ticker, only while loaded, the same on every head including AppKit and GTK4. Pointer-following uses hover, so SpotlightMode.Auto wanders on a phone. On Blazor, colours are CSS strings, Fixed="true" makes it a full-page background, and the effect is drawn on a canvas by a script with no per-frame interop; it pauses when scrolled out of view or in a hidden tab. It ships in the core packages. See AnimatedBackground.

Featureios,android,windows,macos,linux,blazor
New: Avatar and AvatarGroup. Avatar shows a person’s picture when there is one, their initials when there is not, and a silhouette when there is not even a name. The initials sit under the picture, so they show while it loads and stay if it fails, and a broken URL never leaves a hole. Initials come from Name (Grace Brewster Hopper is GH) unless you set Initials. It comes in Circle, Rounded and Square shapes at any Size, and has an optional presence dot (Status = Online, Away, Busy or Offline) coloured from the theme. ColorFromName gives each name its own stable hue, and the hash is the same on both hosts, so a person gets the same colour in a MAUI app and its Blazor twin. AvatarGroup overlaps avatars with a separator ring and collapses everyone past Max into a +N chip. TotalCount lets +N count everyone when only a sample is loaded, and tapping the chip runs OverflowCommand (MAUI) or OnOverflowClick (Blazor) with the hidden count. On MAUI, pictures load through ShinyImage, so they are cached, and a group can bind ItemsSource with an ItemTemplate. On Blazor, size, shape and ring are CSS custom properties that the group sets once. Both ship in the core packages. See Avatar & AvatarGroup.

Featureios,android,macos,blazor
New: VideoText, text with a looping video inside the letters, in the style of Magic UI. The video plays only inside the glyphs, and the control is transparent around them, so a gradient or image behind it shows between the letters. It’s decorative: there’s no transport bar, the video autoplays and loops, and it’s muted unless you set IsMuted to false. FontSize defaults to 0, which auto-fits the text to the control; \n starts a new line. It ships in the MediaElement packages and uses the same player. On iOS, Mac Catalyst and macOS the text is a layer mask on the AVPlayerLayer. On Android, ExoPlayer renders into a TextureView and the text is composited over it with DST_IN. Windows and Linux have no mask yet and show the text in FallbackTextColor. Blazor uses an SVG mask-image sized from the measured glyphs; it can only use system fonts, not web fonts. See VideoText.

Featureios,android,macos,blazor
VideoText: an overlay colour and opacity, and a marquee. The video now plays at full strength inside the letters and sits under an OverlayColor sheet everywhere else. The sheet is black by default, and OverlayOpacity below 1 lets the video show faintly through it. Set the colour to null (Blazor also takes transparent or none) to keep the old look, transparent around the letters. Marquee scrolls the text Left, Right, Up or Down, one pass every MarqueeDuration (8 seconds by default). With a marquee, auto-fit sizes the text to the cross axis only. On Blazor the overlay is an inline SVG, so FontFamily can now use web fonts whenever an overlay is set. Windows and Linux show the label on the overlay colour and scroll it. See VideoText.

Featureios,android,windows,macos,linux,blazor
New: BorderBeam and ShineBorder, animated card outlines in the style of Magic UI. BorderBeam sends a short beam of light around a card’s border, without end. Its head is ColorFrom, it shades into ColorTo, and the tail fades out. You can set its Size, Duration, a Delay head start, Reverse and an InitialOffset. ShineBorder moves a glow in several colours (ShineColors) across the border and back. Each one wraps content, clips it to CornerRadius, and draws the effect over it on a static track (TrackColor, which follows the theme), at BorderWidth. It stops with IsRunning="False" or under OS reduced motion. Both ship in the core packages. MAUI draws them on a GraphicsView, so they look the same on every head, AppKit and GTK4 included. Blazor uses pure CSS, and Overlay="true" decorates an existing element. See Border Animations.

Fixandroid
CarouselGallery: items render at ItemWidth × ItemHeight on Android, with ItemSpacing, and a tap selects them. The shared view holder sized each item to its content after the carousel had set its size, so 280×160 cards came out as small tiles of icon and label huddled together. Items now keep ItemWidth × ItemHeight, and the template fills the item, as it does on iOS. ItemSpacing was ignored on Android and is now applied between items. A uniform PeekAreaInsets also padded the top and bottom, which pushed the card out of the bottom of the carousel; only the left and right insets apply now, and the item is centred vertically, as on iOS. A tapped item now raises ItemSelected/ItemSelectedCommand, which Android never did. The scale effect applies from the first layout instead of after the first scroll, CurrentPosition centres its item (it used to start-align it, so re-enabling snap jumped to another item), and changing ItemSpacing at runtime now takes effect on iOS as well. See CarouselGallery.

Fixios,android
Shiny.Maui.Controls.Desktop now has Android and iOS builds. On a phone it does nothing, as before: no tray, no global hotkeys, no desktop windows, and every service reports itself unsupported. Until now an Android or iOS app got the package’s net10.0 build, which is its Linux build, with the GTK interop compiled in. In an app that references the projects directly (as the samples do), that also brought core’s net10.0 assembly into the Android app, and a clean build then crashed on any page with an Android adapter or listener (CarouselGallery, StaggeredGrid, VirtualizedGrid, FrostedGlassView) with “no Java peer type found”.

Fixios,android,windows,macos,linux
ShinyNavBar: a long title or subtitle now ends in “…” instead of running under the right-hand items. The labels were set to truncate, but they sat in a HorizontalStackLayout, which measures its children with an unbounded width, so they never truncated. The title row is now a Grid whose text column gets the width the bar has left, so Title and Subtitle (on a standalone ShinyNavBar or through ShinyNavigationPage / ShinyNav.Subtitle) end in an ellipsis before the first item. This applies to both leading and centred titles. See NavigationPage.

Fixios,android,windows,macos,linux
TableView: a section with a HeaderView or FooterView no longer crashes when the table re-renders. Each render put the section’s HeaderView/FooterView into a new section layout, but only the cells were taken out of the old one first. On the second render the app crashed: Android threw The specified child already has a parent, and Windows threw a COMException from UIElementCollection.Add. Any re-render caused it, such as changing a TableView styling property (ShowSectionSeparator and others), adding or removing a cell, or a bound section property changing. 1.6.0 fixed this for cells. Header and footer views are now taken out of the old layout too. See TableView.

Fixios,android,windows,macos,linux
TableView: fixed cells in a templated section bind to the section’s item. With TableView.ItemsSource + ItemTemplate, the generated section’s title bound to its item, but a cell declared inside the section template bound to the TableView’s (the page’s) BindingContext, so it showed the page view model instead of the item. Fixed cells, HeaderView and FooterView in a generated section now get the item, and keep it when the TableView’s BindingContext changes. Cells from a section’s own ItemsSource were not affected. See TableView.

Fixios,android,windows,macos,linux
MotionIconView: a hidden looping icon no longer keeps animating. MAUI raises no Unloaded when a view is hidden, so a Trigger="Loop" icon with IsVisible="False" kept redrawing and writing Progress about 70 times a second, pushing every binding on it each frame. Hiding a playing icon now pauses it and releases the shared timer, and showing it resumes from the same point in the cycle. IsPlaying stays true while it is hidden. Only the icon’s own IsVisible is watched; a hidden parent does not pause it. See Motion Icons.

Fixios,android,windows,macos,linux
MotionIconView: Color from a Style, Binding or AppThemeBinding now applies, and a Trigger set after load takes effect. The constructor put the theme’s on-surface colour on Color as a dynamic resource, and in MAUI a dynamic resource outranks a Style setter, a Binding and an AppThemeBinding. Only a local value could recolour the icon, so the docs’ own Color="{AppThemeBinding …}" example, and the sample’s Motion Icons page, stayed in the theme colour. The theme colour is now a fallback that applies only while Color is unset, so any of those wins, and icons still restyle with a live SetTheme. Trigger was read only in Loaded, so a Loop that arrived later (from a late BindingContext or a runtime switch) never started, and switching away from Loop did not stop a running loop. Both now take effect at any time. A looping icon also keeps looping when its colour or artwork changes; before, it stopped after one cycle. See Motion Icons.

Fixandroid
ShinyNavBar: tapping a NavBarItem’s icon now works on Android. The bar wraps each item’s icon in a tappable Border, but the icon is a MotionIconView, a GraphicsView, and on Android that consumes every touch over it. Only a tap on the padding around the glyph reached the item, so on a phone the bar’s icons felt dead. The content of every bar button (items, the back button and the overflow button) now lets the tap through, as ShinyButton, ChipView and ShinyTabBar already did. See NavigationPage.

Fixios,android,windows,macos,linux
Dialogs and Kanban now take the theme’s colours. IDialogService dialogs (Alert, Confirm, Prompt, ActionSheet) set a hard-coded fallback colour and then bound the theme token over it. In MAUI a locally set value outranks a dynamic resource, so the fallback always won: a purple #7C3AED OK button, a white card and grey action sheet options in every theme, dark mode included, and no change on a live theme switch. KanbanView did the same with the Basic theme’s colours, so the board looked right only under Basic. Both now use the theme’s colours, follow a live switch, and fall back to the literal colours only when no Shiny theme is installed. A colour set in DialogConfig still wins. This changes how existing apps look: dialogs now use your theme’s Primary and Surface, so if you relied on the purple OK button, set OkButtonColor. ActionSheet option buttons can now be coloured too, with the new ActionSheetButtonColor, ActionSheetButtonTextColor and DestructiveButtonTextColor (on Blazor as well). See Dialogs.

Fixios,android,windows,macos,linux
No more trim warnings from the MAUI packages. Shiny.Maui.Controls is marked IsAotCompatible, but a full-trim Release (TrimMode=full) still reported IL2026/IL2091 warnings from inside the library: string-path bindings in ChipGroup, the dialog prompt, ProgressLine, Tooltip, DataGrid, the pickers and about twenty other controls, unannotated generics on SetCustomDialogs<T>() and the other SetCustom…<T>() methods, and an IL2091 from ShinyImage (and so ImageViewer) that appears only in the app’s publish, not in the library build. With TreatWarningsAsErrors the app did not build. Without it, ProgressLine and the prompt binding could lose their targets after trimming. Every binding the library makes to its own types is now a compiled, source-generated binding, and the generic and BindableProperty helpers carry the right annotations. The same pass fixed four warnings in Shiny.Maui.Controls.Office and four in Shiny.Maui.Controls.Desktop. The packages now build with the trim analyzer on and trim warnings as errors, so new warnings fail the build. (MAUI’s build targets turn the analyzer off for libraries, which is why these warnings never showed up when the packages were built.) Three properties still name a property of your model as a string, so they are read by reflection: ChipGroup.DisplayMemberPath, DisplayMember/SubDisplayMember on PickerCell and TextPickerCell, and AutoCompleteEntry.TextMemberPath. A DataGrid column’s PropertyName works the same way. In a full-trim app, use ItemDisplayBinding or ValueGetter, or leave the path unset and override the item’s ToString().

Featurewindows
Camera: barcode scanning works on Windows. BarcodeScanner was a no-op on Windows because there is no camera-based native scanner there, so BarcodeAnalyzer, IMediaService.ScanBarcodeAsync/ScanBarcodesAsync and the driver’s-licence PDF417 read returned nothing, and an app that pairs by QR code with a webcam had to bring its own decoder. Windows now decodes the frame with the managed ZXing.Net reader (the one Shiny.Maui.Controls.Barcodes already uses to render codes). Formats filters the symbologies, every code in the frame is reported, and ScanWindow is applied as a crop, so codes outside it are not read. Nothing changes on iOS, macOS or Android, which still use Apple Vision and ML Kit. See CameraView analyzers.

Featureios,android,windows,macos,linux
ShinyFlyout: set PushMode and the other view options on the host. Only StartTemplate and EndTemplate could be declared on the Shell or NavigationPage. PushMode, ScrimColor, ScrimOpacity and IsAnimationEnabled lived on the FlyoutView that ShinyFlyout creates for each page, so an app had to find it with ShinyFlyout.GetFlyoutView(page) after every navigation to set them. They are now attached properties too (shiny:ShinyFlyout.PushMode="Resize"). Each is copied onto the view installed on every page, and a later change reaches the pages already showing. Panel options such as IsSwipeEnabled still go on the FlyoutPanel in the template. See Shell & App-Wide Flyouts.

Featureios,android,windows,macos,linux
Slider: bind the stop points with MarksSource. Slider.Marks is a get-only list, so marks could only be declared in XAML or added from code-behind, and a view model had no way to supply them. The new bindable MarksSource takes any IEnumerable of SliderMarks or plain numbers (a number becomes an unlabelled mark at that value). The marks are drawn and snapped to alongside any declared in Marks. An ObservableCollection source is followed as items are added or removed, and editing a mark in it redraws that mark. Blazor needed no change: marks there are child components, so an @foreach over your data already works. See Slider.

Featureios,android,windows,macos,linux,blazor
Slider and RangeSlider: DragCompleted reports the value once, on release. ValueChanged/ValueChangedCommand (and RangeChanged/RangeChangedCommand) fire on every step while a thumb is dragged, so an action that should happen once (saving, a network call, sending a command to a device) had to be debounced in the view model. The new DragCompleted event and DragCompletedCommand (MAUI), or DragCompleted callback (Blazor), are raised once with the final value (a SliderRange / (Lower, Upper) on RangeSlider) when the thumb is released. A cancelled drag counts as a release, and a tap or click on the track raises it right after moving the thumb. It is not raised when the value ends up where it started. See Slider and RangeSlider.

Fixios,android,windows,macos,linux
SetCustomToaster<T>() now registers your toaster. It registered the built-in Toaster whatever T was, so a custom IToaster was never used.

Fixios,android,windows,macos,linux
ButtonGroup: a Style can position the group, and a styled Appearance no longer hides the selection. The constructor set HorizontalOptions = Start and VerticalOptions = Center as local values, and a local value outranks every Style, so a Style centring the group never applied. The defaults are now supplied without a local value, so a Style (implicit or explicit) or a value you set wins. With a selection mode on, a segment’s own Appearance is kept as its unselected look, but the check also matched an Appearance from a Style: a Style making every ShinyButton Filled left every segment Filled, and since SelectedAppearance defaults to Filled the selected segment looked like the rest. Only a value set on the segment itself is kept now; a styled segment takes UnselectedAppearance, and gets its Style back when the selection mode is turned off. The same default-alignment fix applies to ButtonGroupText. See ButtonGroup.

Fixios,android,windows,macos,linux
SecurityPin: the cells are visible without setting any colours. CellBorderColor, CellBackgroundColor and their focused variants default to null, and a null was simply not written, so a SecurityPin with no colours drew cells with no outline and no fill. This was easy to miss, because the MAUI template’s app-wide implicit Border style stroked them. Unset colours now follow the theme, as the docs described: the outline is outline, the fill is surface-container-low, the active cell’s outline is primary, and the digits are on-surface. If you set only CellBorderColor, the active cell keeps it, as before. The active cell’s colours also update when focus enters or leaves the pin; before, a cell kept its focused colours after focus moved on. See SecurityPin.

Fixios,android,windows,macos,linux
ChipGroup: the selected chip’s ItemTemplate text uses the selected ink. The selected text colour was painted only on the chip’s own label, which an ItemTemplate hides, and ChipView is internal, so the template could not bind to it. The selected chip’s text kept the unselected colour, which can be hard to read on a strong SelectedChipBackgroundColor. Every Label in the template now takes the chip’s ink (ChipTextColor/SelectedChipTextColor, or the theme’s colours). A label that sets its own TextColor, as a local value or through its own Style, keeps it; an app-wide implicit Label style does not count. A TextColor binding on a template label is overwritten by the ink. See ChipGroup.

Fixios,android,windows,macos,linux
ChipGroup: a SelectedItem that arrives before ItemsSource is shown. When SelectedItem got its value first (from XAML attribute order, or a view model that picks a default while building its list), the group checked it against items that did not exist yet and dropped it. Nothing looked at it again when the items arrived, so no chip was selected while SelectedItem and the bound property still held the value. The group now selects it as soon as the items contain it, whatever order the two arrive in, and SelectedItems follows. A SelectedItem the items never contain still selects nothing. See ChipGroup.

Fixandroid,ios,windows,macos,linux
Wizard: typing into a step whose IsValid is bound to the typed text no longer crashes on Android. Every change to a step, its validity included, made the StateView inside the wizard re-host the step’s view, and it did so synchronously, while the field’s own Text write was still running. Detaching the field cleared its inherited BindingContext, so its TwoWay binding wrote the default back into it in the middle of the input. On Android the keyboard then committed into text that had been replaced, and the app crashed with IllegalArgumentException: end should be < than charSequence length in EmojiCompat. This is the pattern the docs and the sample use (IsValid="{Binding EmailIsValid}" with a field bound to Email). Changes to a step’s metadata (IsValid, Title, Description, IsVisible, IsEnabled, IsOptional, IsCompleted, NextText, BackText) now only refresh the wizard’s progress and buttons, and the step’s view stays where it is. Changing a step’s Content or ContentTemplate still re-hosts it. See Wizard.

Fixblazor
ImageEditor: a trim warning in the JS interop helper is fixed. It was the one trim warning left in Shiny.Blazor.Controls.

Fixblazor
TableView: clicking a switch, checkbox or radio cell’s control no longer undoes itself. CellBase wrote onclick:stopPropagation as a literal DOM attribute, which does nothing, so the click bubbled to the row’s tap handler and toggled the value straight back. The stop is now a real event modifier, applied only to cells whose accessory is a live control, so taps on Command and SimpleCheck rows still reach the row. Clicking a RadioCell’s radio now also leaves it checked: its change handler expected a bool, but a radio reports the string "on". See TableView.

Fixblazor
Ribbon: showing a contextual tab and selecting it in the same render now selects it. The ribbon checked the requested tab before the tab’s Visible arrived and fell back to the first tab. A requested tab that isn’t selectable yet is now kept pending and selected once it appears. See Ribbon.

Fixblazor
ShinyToolbar: aria-expanded renders as "true"/"false", and clicking outside an open menu closes it. The attribute was bound to a bool, so it rendered empty or was dropped. The click-outside backdrop is a popover, and the browser’s fit-content popover sizing beat inset: 0, so it was only 14 px square and an outside click never reached it. See Toolbar.

Fixblazor
MermaidDiagram: changing Theme redraws the diagram. It only re-rendered when Diagram changed. See Mermaid Diagrams.

Fixblazor
ProgressLine: lines shown through the service use their configured colours and offset. ProgressLineHost passed the configuration strings without @, so the literal text config.BarColor reached the CSS. See ProgressLine.

Fixblazor
ChatView: Enter-to-send no longer leaves a newline behind in the composer. The keydown was never cancelled. Shift+Enter and IME composition are unaffected. See ChatView.

Fixblazor
ModalView: a ModalButton.OnClick that sets the bound IsOpen = false closes the modal. OnClick is a Func<Task> and was called directly, so the page that owns it never re-rendered and the false never reached the modal. It is now invoked the way Blazor invokes an event callback, so it re-renders its page. The API is unchanged. See Modal.

Fixblazor
DateRangeCalendar: “Next month” pages a multi-month calendar. With Months="2", paging went through a check that skips a month already on screen, and the next month is always the second one visible. See Range Pickers.

Fixblazor
Scheduler: with drag or resize enabled, a plain click on an agenda event selects it again. The agenda armed a drag on pointer-down and then swallowed the following click even when the pointer never moved. A click is now only swallowed after a real drag (past an 8 px slop). See Scheduler.

Fixblazor
KanbanView: a pointer drop on a blocked lane raises OnDropRejected. The drag script threw the blocked lane away, so the refusal (for example WipLimitReached) was silent although the docs promised the event. See Kanban.

Fixblazor
RangeSlider: releasing a thumb drag no longer jumps the other thumb. The release also counted as a click on the track, which moved the nearer thumb to that point. See RangeSlider.

Fixblazor
PasswordStrength: the Show/Hide toggle relabels as soon as it is clicked. The label only updated on the next parameter change, so after Show the field was revealed but the button still read “Show”. See PasswordStrength.

Fixblazor
QuickEntry: Escape in the popup backs out one step at a time. One press both cleared the highlighted suggestion and closed the popup, because the key bubbled from the prompt to the host, which handled it again. See Quick Entry.

Fixblazor
Accordion: AllowCollapseAll applies at runtime, and LoadContentOnDemand reaches markup children. Turning AllowCollapseAll off on a fully closed accordion now opens the first item, as on MAUI. LoadContentOnDemand only reached expanders generated from Items; @foreach children now inherit it, and an expander that sets its own value keeps it. See Expander.

Fixblazor
ShinyButton: the docs now explain what a faulting handler does. A Clicked handler that throws is shown as Error and then rethrown, by design, and an exception that escapes it is fatal on Blazor Server. Catch failures in the handler and drive a bound State instead. The sample’s “This will fail” demo now does this. See Button.

Fixblazor
GamepadView, MediaElement and File Drop work in a published (trimmed) WebAssembly app. Each passes a type across JS interop that the trimmer stripped in a Release publish, and only there. GamepadView’s options were a positional record, which lost its constructor parameter names, so serializing it threw ConstructorContainsNullParameterNames and the pad never attached. MediaElement’s status and the File Drop payload lost their constructors, so every player event and every drop threw DeserializeNoConstructor: the transport bar never updated, and dropped files never arrived. The types are now explicitly kept alive. Debug builds and Blazor Server were never affected. See Gamepad, MediaElement and File Drop.

Fixios,maccatalyst
CameraView: flipping the camera while leaving the page no longer crashes on iOS. Reconfiguring the input finished on the main thread after a hop through the camera session queue, then read the view’s zoom and torch, but by then the handler could already be disconnected. That read threw with nothing to catch it, and the app aborted. Both reads now stop quietly once the view has gone. See CameraView.

Fixios,android,windows,macos,linux
FabMenu: closing the menu during its open animation closes it. A change to IsOpen that arrived while the open animation was still running was dropped. IsOpen read False, but the items stayed on screen. The menu now re-checks IsOpen when each animation finishes and follows it. See FAB.

Fixios,android,windows,macos,linux
MotionIconView: IsPlaying="True" loops until you clear it. It played RepeatCount cycles (one by default) and then flipped IsPlaying back to False, so an icon bound to a busy flag stopped after about a second. It now loops while IsPlaying is true, as on Blazor and as the docs describe. RepeatCount still limits Play(), Press and Appear. See Motion Icons.

Fixios,android,windows,macos,linux
TableView: the table’s value-text styles reach its cells. CellValueText* set on the TableView were never applied to LabelCell, EntryCell or the picker cells, so values kept the default font and colour while titles and descriptions followed the table. A cell’s own value-text settings still win. See TableView.

Fixios,android,windows,macos,linux
TagEntry: pasting a tag that already exists no longer deletes the newest chip. The field was rewritten from inside its own TextChanged, and MAUI defers that write. When it landed, it looked like a Backspace in an empty field and removed the last tag. See TagEntry.

Fixios,android,windows,macos,linux
ZoomPanView: a zoom or reset requested during a zoom animation is no longer lost. A ZoomLevel change or Reset that arrived mid-animation was ignored, leaving the view at the old zoom, or at 1× with IsZoomed still True. The new request now replaces the running animation. See ZoomPanView.

Fixios,android,windows,macos,linux
Accordion: ExpandedIndex follows rule changes. When changing AllowCollapseAll or SelectionMode opened or closed items, ExpandedIndex kept its old value (for example -1 while the first item was open). See Expander.

Fixios,android,windows,macos,linux
Walkthrough: Stop and Start behave during start-up and teardown. A Stop while the tour was starting was reported as “Tour completed” and marked the tour as run. A Start made while the tour was closing could launch a run that the closing tour then dismantled, leaving it stuck. A Stop during start-up now ends the tour as Stopped, and a Start during teardown is queued and runs once the tour has closed. See Walkthrough.

Fixandroid
FrostedGlassView: no more per-frame allocation on Android. The blur ran on every frame and each time created a new Bitmap (never recycled), Canvas, RenderEffect and outline provider. While a frosted view was on screen, the app gained about 57 JNI global references a second, until garbage collection caught up. Under load, or with several frosted views, that could push the app toward Android’s global-reference limit, where it stalls in back-to-back full GCs. The bitmap and canvas are now reused and only rebuilt when the view is resized, the effect and outline are created once, and all of them are disposed. On a fresh process, references now stay flat while the view is on screen. See FrostedGlass.

Fixios,android,windows,macos,linux
Ribbon and the Office editors: one rebuild per change, not one per property. Every Ribbon property that changes its layout, and every tab or quick-access item added or removed, rebuilt the whole bar: every tab, group and item view. The Document Editor, Slide Editor, Spreadsheet and Notebook rebuild their bars by clearing and re-adding every tab, and their ShowShell switch changes several of those properties at once. One shell toggle therefore meant up to ten full rebuilds and about a thousand new handlers. On Android that pushed the app past its JNI global-reference limit by the second toggle, after which it stalled in back-to-back full garbage collections. Those changes are now batched into a single rebuild, and the editors’ File command is created once instead of on every toggle. On Android, six toggles of each editor now stay flat, with no GC pressure. See Ribbon.

Fixandroid
Ribbon: the page no longer crashes on Android when it opens. A property change made while the Ribbon was building its bar (resolving command bindings were enough to cause one) started a second, nested build. Both builds added the bar’s edge fades, so the same views sat in the layout twice. iOS tolerated this, but Android threw “The specified child already has a parent” and the page never opened. A rebuild requested during a build now runs once, after it. Once the bar has a native view, a rebuild also waits for the next main-loop turn instead of running in the middle of view realization. See Ribbon.

Fixandroid
MediaElement: playing a video no longer crashes on Android. Media3 1.10’s Player.Listener callbacks are Java default methods, but the binding sends each one to managed code. Every callback the player listener did not implement (onSurfaceSizeChanged, onEvents, …) threw AbstractMethodError and ended the app as soon as playback started. All of the listener’s callbacks are now implemented. See MediaElement.

Fixios,android,windows,macos,linux
Scheduler calendar list: jumping to a far-off SelectedDate shows that date. Paging in more days (scrolling back to the top, or reaching the end) wasn’t tied to the list’s current load. A page still in flight when SelectedDate changed finished afterwards, inserted the old range’s days into the new list and reset the range back to them. The list then kept paging through the old dates and the new one never appeared. This happened reliably on Android, which reports a scroll when the list is cleared. Paging now stops when the list re-centres, and is ignored while the first page is still loading. See Scheduler.

Featureios,android,windows,macos,linux,blazor
New: Range pickers - date range, time range and date/time range. There are four new controls in the core packages of both hosts:

  • DateRangeCalendar: an inline calendar. Tap the first day, then the last.
  • DateRangePicker: a field (“Mar 3 – 9, 2026”) that opens the calendar in a popup.
  • TimeRangePicker: start and end columns. Each end time shows the length it gives, and moving the start keeps that length. Overnight ranges are optional.
  • DateTimeRangePicker: a calendar with start and end times.

Each picker edits a draft that only Apply commits.

Ranges can be limited by min/max dates, min/max spans and blocked days or weekdays. A range can’t run across a blocked day unless you allow it, and while the end is being chosen, the days that can’t end the range are greyed out. Presets cover reporting (Last 7 days, Last month…) and booking. Field text is compact and culture-aware: “Mar 28 – Apr 2, 2026”, or “3. – 9. März 2026” in German.

The rules live in a new dependency-free engine package, Shiny.Controls.RangePickers.Shared, which you can also use with no UI. MAUI binds DateTime?/TimeSpan? and Blazor binds DateOnly?/TimeOnly?. See Range Pickers.

Featureios,android,windows,macos,blazor
New: Marquee. Content that scrolls endlessly, in the style of Magic UI’s marquee, in the core packages of both hosts.

  • Angle sets the direction: 0 scrolls left, 90 up, and any other value travels diagonally.
  • Reverse, Duration or Speed, and Gap control the motion. Copies are added automatically to fill the viewport.
  • Optional: pause on hover or press, faded edges, and upright or tilted items at an angle. It holds still when the OS asks for reduced motion.
  • MAUI fills it from ItemsSource + ItemTemplate; Blazor repeats the ChildContent.

See Marquee.

Featureios,android,windows,macos,blazor
New: Confetti. Bursts of confetti in the style of Magic UI’s confetti, in the core packages of both hosts. On MAUI, add shiny:Confetti.Trigger="Tap" (or DoubleTap) to any view. Buttons (Button, ImageButton, ShinyButton, Fab) are hooked through Clicked and burst from their centre; any other view bursts from the point you touched. On Blazor, wrap the element in <Confetti>. The click is handled in JavaScript, so the burst starts with no .NET round trip. In code, IConfettiService fires a preset or a custom ConfettiOptions from a point or from an element, and the returned task completes once the last particle has faded. There are five presets: Burst, Random, Fireworks, SideCannons and Stars. ConfettiOptions uses canvas-confetti’s names and defaults and adds stars, emoji and reduced-motion opt-out. See Confetti.

Featureios,android,windows,macos,linux,blazor
New: Keyboard Shortcuts. Declare shortcuts on a page, a view or a dialog — <shiny:KeyboardShortcut Gesture="Primary+S" Command="{Binding SaveCommand}" /> in XAML, <KeyboardShortcut Gesture="Primary+S" OnPressed="Save" /> in Blazor — or register them app-wide through IKeyboardShortcutService. Primary is ⌘ on Apple platforms and Ctrl elsewhere, and gestures display that way (⇧⌘P / Ctrl+Shift+P). Modifiers match exactly. Letters follow the keyboard layout (and fall back to position on non-Latin layouts), digits match by position, and ?-style character gestures ignore Shift. Plain keys stay with a focused text field. Also: chords (Ctrl+K, Ctrl+C) with a timeout, opt-in auto-repeat (repeats are always swallowed), and Released/ReleasedCommand for push-to-talk, which also fires when the window loses focus. Precedence: a view beats its page, and a modal scope blocks everything outside it; Shiny’s in-app dialogs are modal and close on Escape. GetActiveShortcuts() feeds a cheat sheet. Key sources: Windows, Android and iOS hardware keyboards in core; macOS (AppKit), Linux (GTK4) and Mac Catalyst via UseDesktopKeyboardShortcuts() in Shiny.Maui.Controls.Desktop; the browser in Shiny.Blazor.Controls, matched in JS so preventDefault is decided synchronously. The rules live in the new shared engine Shiny.Controls.Keyboard.Shared. See Keyboard Shortcuts.

Featureios,android,windows,macos,linux,blazor
Notebook: hide the page list. NotebookEditorView has a new ShowPageList property (two-way, default true) and a matching Page list toggle on the View tab. It hides only the page list down the right, so the canvas gets the full width and the section tabs stay. This matters most on a phone, where the list took about half the screen from the page: ShowPageList="{OnIdiom Phone=False, Default=True}". ShowNavigation still hides the tabs and the list together. See Notebook.

Featureios,android,windows,macos,blazor
IMediaService: the full MAUI 10 MediaPickerOptions set. PhotoCaptureOptions, MediaPickOptions and the service-wide MediaServiceOptions gain MaxWidth and MaxHeight, RotateImage and PreserveMetadata, alongside the existing CompressionQuality, MaxDimension and OutputFormat.

  • The size limits combine: the tightest wins, aspect is kept, and nothing is upscaled.
  • RotateImage (default true) bakes the EXIF orientation into the pixels. With false, the orientation tag is always kept.
  • PreserveMetadata (default true, like MAUI) keeps EXIF, GPS and TIFF fields, with orientation, dimensions and the thumbnail corrected. Set it to false to strip location before an upload.

Processing is now native on every platform: ImageIO on Apple (HEIC included), BitmapFactory on Android and BitmapDecoder on Windows. On Blazor, MaxWidth/MaxHeight apply to captures and picks, and PreserveMetadata splices a picked JPEG’s EXIF across the canvas re-encode. See IMediaService.

Fixios,android,windows,macos
IMediaService: resized photos no longer come back sideways or stripped. Every resize or recompress went through PlatformImage, which dropped all metadata. At the default 92% quality, that was nearly every photo. On Android, CameraX stores sensor-orientation pixels and records which way is up only in the EXIF tag, so TakePhotoAsync returned the picture rotated 90° with nothing left in the file to correct it. Photos are now upright by default, and EXIF and GPS survive the re-encode.

Featureios,android
CameraView: FocusMode keeps autofocus off the windscreen. Continuous full-range autofocus — the only behaviour until now — has no idea the glass in front of the lens is not the subject, so a camera mounted behind a windscreen hunts onto rain drops and smears and leaves the road soft. FocusMode="Far" restricts autofocus to distant subjects (Apple; Android has no range restriction and treats it as Infinity), and FocusMode="Infinity" turns autofocus off and parks the lens at its far limit, which is how a fixed-focus dash cam works. It is applied to the device rather than the session, so changing it mid-recording interrupts nothing. The default stays Auto.

Featureios,android,windows,macos,blazor
PasswordStrength: time to crack, and a minimum time as policy. The built-in evaluator now estimates how long the password would hold out against an attacker making GuessesPerSecond guesses a second (default 10 billion, an offline attack on a fast hash). The estimate is on the verdict as TimeToCrackSeconds / TimeToCrack, and ShowTimeToCrack shows it under the meter as “Time to crack: 21 days”. MinimumTimeToCrack makes it policy: it adds an “At least 1 century to crack” row to the checklist, and the password is not acceptable until the estimate reaches it, even if it already meets the length rule. The duration wording is localizable unit by unit, and PasswordCrackTime.Describe formats a value the same way. See PasswordStrength.

Featurewindows,macos,linux,blazor
Docking: Visual Studio-style dragging and real floating windows. Pull a tab out of its strip and it becomes a translucent floating window that follows the pointer (on Blazor it shows a live snapshot of the panel). A docking compass appears over the pane underneath, with a centre guide to add the panel as a tab and four arms to split the pane, and four guides sit on the host’s outer edges to dock against that side of the layout. Only a hovered guide is a target, and a preview shows where the panel will land. Let go anywhere else and the panel floats where you dropped it. Floating windows now have a title bar with Dock and Close buttons, an accent title bar when they hold the active panel, and resize handles on every edge and corner. Drag a window by its title bar onto a guide to dock the whole window back, keeping its splits on an edge drop. Double-click a tab to float it, and double-click a floating title bar to return it to the group it came from (new DockWindowState.RestoreGroupId). New IDockHost.FloatPanelAsync. The chrome got a pass too: an accent bar on the focused pane’s active tab, close buttons that show on hover, drawn icons instead of text glyphs, and tabs that shrink with an ellipsis in narrow rails. See Docking.

Fixios,android,windows,macos,blazor
Slides: text with no colour of its own now follows the master and the theme. Default run formatting stopped at the first list style it found, usually the layout’s, so a layout that only set sizes hid the master’s txStyles, the master placeholder and the presentation’s default text style, and uncoloured text fell back to black. Scheme colours tx1/bg1/tx2/bg2 also ignored p:clrMap and p:clrMapOvr. Formatting now resolves run → paragraph → shape → layout placeholder → master placeholder → master text styles → presentation default, and tx1 maps through the slide, layout and master colour maps. Dark themes (Midnight, dark variants) no longer draw black text on navy in SlideView, the slide editor or the slideshow. See Slide Viewer.

BREAKINGios,android,windows,macos,blazor
Document editor: DocumentPosition.Block now indexes the story, not the top-level blocks. Positions index WordDocument.Paragraphs — every paragraph in reading order, table cells included — which is what lets the caret, find, replace and spelling reach into tables. WordDocument.Blocks is still the top-level list the layout walks. Code that stored a block index or used one against Blocks must map with TopBlockOf(paragraph), FirstParagraphOf(top) and CellOf(paragraph). See Tables.

BREAKINGios,android,windows,macos,blazor
The Office editors now open as Word, Excel and PowerPoint windows by default. DocumentEditorView, SpreadsheetView and SlideEditorView show the ribbon’s tab strip and the new Office shell (title bar, status bar, File backstage) out of the box. SpreadsheetView.ShowToolbar now defaults to true. ShowShell="false" restores each view’s previous layout; a read-only spreadsheet viewer also sets ShowToolbar="false", and a host that wants the old single-row Word or PowerPoint bar sets ShowRibbonTabs="false". In the Word ribbon Proofing moved from Home to Review and zoom from Layout to View. Give the Blazor Word host about 700px of height.

Featureios,android,windows,macos,blazor
New: Office Shell — the window around the Office editors. OfficeShell arranges an accent title bar (AutoSave, quick access, rename-in-place document name, save status and a “Search for tools” command search over every ribbon command and shortcut), the ribbon with Comments / Editing-Reviewing-Viewing / Share at its right end, a ruler with draggable indents and tab stops, a navigation pane (headings, search results), side panes, a status bar with view modes and a 10–500% zoom slider plus Word’s Zoom dialog, the File backstage (templates, recent files, info, save as / export / print with preview, options) and focus mode. Each part works alone; the shell reads and writes no files — every task is an event for the host. Responsive below 600px. See Office Shell.

Featureios,android,windows,macos,blazor
Document editor: Word’s feature set. Tables you can type in, with a contextual Table tab (insert/delete rows and columns, merge and split cells); find and replace (Replace All is one undo step); a formatted clipboard and Format Painter; grow/shrink font, change case, sub/superscript and clear formatting; line spacing, indents, shading, borders and Show ¶; the “AaBbCc” Styles gallery (writing Word’s own style definitions); hyperlinks, bookmarks, date & time, symbols and section breaks; a Word-compatible table of contents and footnotes; comments with margin balloons and a comments pane; track changes with accept/reject; word count; page colour, paper size, orientation and a persisted text watermark; tab stops on the ruler; Read Mode and a navigation pane; PDF export (ExportPdf, DocumentPdfExporter) and Word’s keyboard shortcuts. Columns are saved but still drawn single-column. See Document Editor and its Formatting, Tables, References, Review and Page Layout pages.

Featureios,android,windows,macos,blazor
Spreadsheet: Excel’s feature set. Merge cells, freeze panes, borders, sort (multi-level) and AutoFilter, the fill handle with Ctrl+D / Ctrl+R, conditional formatting (highlight rules, top/bottom, data bars, colour scales), data validation with in-cell dropdowns, cell styles and Format as Table, column/bar/line/pie/area charts you can move and resize on the grid, notes, hyperlinks, formula autocomplete with signature help, Insert Function, defined names and a Name Manager, and about 140 functions (XLOOKUP, XMATCH, SUBTOTAL, AGGREGATE and the financial set — PMT, PV, FV, NPV, IRR, RATE, NPER, IPMT, PPMT, SLN…; no dynamic arrays). Also zoom (10–400%), SelectionStatistics, Show Formulas and view toggles, the Format Cells dialog and context menus, Excel’s shortcuts, CSV and PDF export, and Page Layout / Page Break Preview views. The ribbon is laid out as Excel’s (Home, Insert, Formulas, Data, Review, View). See Spreadsheet and its Formatting, Data, Formulas and Charts pages.

Featureios,android,windows,macos,blazor
Slide editor: PowerPoint’s feature set. A Shape Format tab (fill, outline, effects, quick styles, size, align, distribute, rotate, flip and a rotation handle), multi-select with a marquee, group and ungroup, smart guides; text parity (soft line breaks, change case, sub/superscript, line spacing, vertical alignment, autofit); hyperlinks on text and shapes; a Design tab with seven themes and their variants, slide size and backgrounds; Transitions (Fade, Push, Wipe, Split, Reveal, Cover, Zoom, Morph) and Animations (11 effects, triggers and an animation pane); a Slide Show tab with presenter view and hidden slides; charts, icons, audio and video, header & footer; a View tab with Outline, Slide Sorter, Notes Page, Slide Master, zoom, ruler, gridlines and guides; sections; and PNG / PDF export. SlideEditorView wears the Office shell — title bar, backstage and a “Slide X of Y” status bar with zoom and fit. See Slide Editor and its Shape Format, Design, Transitions & Animations, Charts & Media and Slide Show pages.

Featureios,android,windows,macos,blazor
Ribbon: shortcuts, a header-end slot, galleries, number boxes and a command list. Every item takes a Shortcut (“Ctrl+B”), shown in its tooltip as Office does. HeaderEndContent / HeaderEnd puts content at the far right of the tab strip. RibbonGallery is an in-ribbon preview gallery (Word’s Styles) that steps a row at a time and drops the full grid; RibbonNumberBox is a labelled number field with a unit and spinner. GetCommands() returns every command — dropdown leaves and gallery entries included — with its category, shortcut and an invoke delegate, for a command search (Blazor indexes tabs as they render and raises CommandsChanged). See Ribbon.

Fixios,android,windows,macos
TableView: a switch that shows a section no longer hangs the app. When a SwitchCell was bound to the same value as a TableSection.IsVisible, turning it on froze the app. The table re-rendered in the middle of the switch’s own change and detached every cell. A detached cell loses the BindingContext it inherited, so the bound On dropped to false and was written back into the switch still being set. MAUI replayed that write, turning the value off, which re-rendered again, forever. Turning it off was unaffected. Section and root changes now re-render on the dispatcher just after the change, and several sections changing from one write re-render once. See TableView.

Fixmacos,linux
Theme tokens resolve on the maui-labs AppKit and GTK backends. UseShinyControls merged the theme from MAUI’s own ApplicationHandler and PageHandler mappers. The maui-labs macOS (AppKit) and Linux (GTK4) backends use their own application and window handlers, so the theme was never merged there and every token-bound colour was unset. The quick entry card, for example, had no fill, and its text sat over whatever was behind the window. The theme is now merged when the app or a window gets its handler, on every backend.

Featureios,android,windows,macos,blazor
New: YogaLayout — Yoga’s layout model on MAUI and Blazor. Yoga is Meta’s open-source flexbox engine behind React Native. YogaLayout is an independent implementation of its model: pure C# on MAUI with no native library, and CSS with Yoga’s defaults on Blazor, so the same properties give the same boxes on both hosts. It has Yoga’s defaults (column, FlexShrink 0, AlignContent FlexStart, minimum size 0) and extras: absolute PositionType with Left/Top/Right/Bottom/Start/End insets, percentage lengths ("50%"), AspectRatio, auto margins (AutoMargins="Start"), Display, and Gap/RowGap/ColumnGap. On MAUI the child properties are attached, and the sizes are NodeWidth/NodeHeight because Width belongs to VisualElement. On Blazor every node is a nested <YogaLayout>. Credit to the Yoga project for the model. See YogaLayout.

Featureios,android,windows,macos
New: ShinyFlexLayout, a fast replacement for FlexLayout. It has the same properties and Microsoft.Maui.Layouts enums (Direction, Wrap, JustifyContent, AlignItems, AlignContent, attached Grow/Shrink/Basis/AlignSelf/Order), plus RowSpacing/ColumnSpacing gaps. MAUI’s FlexLayout measures every child in every pass. This one caches: a pass at the same size measures nothing, a changed child re-measures only itself (including a handler-side change deep inside it, such as an image loading), a rotation reuses leaf measurements, and a pass allocates nothing. On an iOS simulator with 1000 wrapped labels it takes 0.9 ms per pass against about 23 ms for FlexLayout. Shrink follows CSS, so an overflowing line fits instead of spilling past the container. MAUI only; Blazor has native CSS flexbox. See ShinyFlexLayout.

Featureios,android,windows,macos,blazor
New: Gamepad — an on-screen controller that is a real Shiny.Gamepad IGamepad. GamepadView draws NES, SNES, Standard (Xbox / PlayStation / Switch Pro), twin-stick and arcade layouts, relabelled with Xbox, PlayStation, Nintendo, Super Nintendo or NES face styles. Lay it over a game (touches that miss it pass through to the game) or place it inline as a scaled controller with a body. It is an IGamepad of GamepadKind.Virtual, and UseShinyGamepad() (MAUI) / AddShinyGamepad() (Blazor) list it in IGamepadManager.GetGamepads() beside physical controllers, so game code reads both the same way. Multi-touch sticks and d-pads (native on iOS and Android), 8- or 4-way d-pad, floating sticks, double-tap stick click, thumb-roll chords between neighbouring buttons, turbo, button and d-pad haptics as separate switches, device vibration through SetVibration, auto-hide when a physical controller connects, idle fade, and a drag-and-pinch layout editor whose result saves as JSON. Three new packages: Shiny.Maui.Controls.Gamepad, Shiny.Blazor.Controls.Gamepad and the shared engine Shiny.Controls.Gamepad.Shared. See Gamepad.

Featureios,android,windows,macos,blazor
Slide editor: new, duplicate, delete and reorder slides. SlideEditorView gains a Home ▸ Slides group — New slide, Duplicate, Delete, Earlier and Later — backed by NewSlide, DuplicateSlide, DeleteSlide, MoveSlide, MoveSlideEarlier and MoveSlideLater on SlideEditorController, all undoable. A new slide takes the current layout (Title and Content after a title slide) and its empty placeholders show editor-only “Click to add title / text” prompts. Delete is confirmed first (ConfirmSlideDelete, or your own dialog via ConfirmDeleteSlide), and undoing it restores the same slide even after a save. Sections and custom shows stay consistent. Ctrl+M adds a slide on Blazor. See New, Delete & Reorder Slides.

Featureios,android,windows,macos,blazor
Slide editor: a thumbnail rail with drag-to-reorder. SlideEditorView shows the deck as a column of thumbnails beside the slide (ShowSlideRail, hidden below 600px). Tap to open a slide, drag to move it; the rail auto-scrolls while dragging and the drop is one undo step. The logic is the shared SlideRailController; SlideRail is usable on its own.

Featureios,android,windows,macos,blazor
Slide editor: editable speaker notes and layouts. The status bar’s Notes button opens a notes box that writes through as you type and undoes as one step, creating the notes page — and the notes master, if the deck had none — as needed. Home ▸ Slides ▸ Layout re-lays the current slide on another of the master’s layouts, carrying its content across, or adds a new slide with one (Layouts, SetLayout, NewSlide(layout), SetNotes).

Featureios,android,windows,macos,blazor
Slide editor: nudge, stacking order and a shape clipboard. Arrow keys move the selected shape (fine with Alt/Ctrl). Insert ▸ Arrange brings shapes forward or back. Insert ▸ Clipboard — Copy, Cut, Paste, Duplicate, and Ctrl+C/X/V/D on Blazor — copies shapes with their pictures, between slides or decks.

Featureios,android,windows,macos,blazor
Slide editor: edit inside groups and table cells. A click selects a whole group and a double-click goes into it; children are drawn through the group’s own scale and moved in its units. Double-click a table cell to type in it — every text command works there — and Tab moves between cells.

Fixios,android,windows,macos
A closed FloatingPanel no longer covers the page. With IsContentScrollEnabled="False" (the documented setting for content that scrolls itself, such as a CollectionView) and ShowHeaderWhenClosed="True", the closed panel hid its ScrollView, which is not used when scrolling is off, so the whole body stayed on screen under the peeking header. It covered the page and blocked taps and scrolling on everything under it. A closed panel now shows only its header in either mode. Flipping IsOpen while the panel is still opening or closing now reverses it instead of being ignored, which used to leave the panel open with IsOpen false. The backdrop stops taking touches as soon as it starts fading out, so a fade that never finishes (the page leaving the screen mid-animation) cannot leave an invisible layer over the page. See FloatingPanel.

Fixios,android,windows,macos,blazor
Document and slide editors: Bold, colour and the other font buttons now change the word under the caret. In both editors, with the caret in a word and nothing selected, the change was only held for the next thing typed, so the text on screen did not change. As in Word, a caret inside a word now formats the whole word. At the edge of a word or between words the change is still held for typing. Separately, in the document editor, turning bold, italic, underline or strikethrough off had no effect on text that got it from its style, such as a heading, because only the run’s own setting was removed. It now writes an explicit “off” that overrides the style.

Fixios,android,windows,macos,blazor
Tables on a slide can be moved and resized. Dragging a table’s frame or handles silently did nothing, because its transform was never written.

Fixios,android,windows,macos,blazor
Merged table cells draw in the right place, and scaled groups draw at their real size. A cell after a horizontally merged one was shifted right by an extra column, and a group’s children were drawn in its child units rather than scaled into the group’s frame.

Fixios,android,windows,macos,blazor
Shapes added in the slide editor get unique ids. Every inserted text box, shape, picture and table used to carry drawing id 2.

Featureblazor
IMediaService comes to Blazor. AddShinyMediaService() plus one <MediaHost /> in your layout gives Blazor the same service MAUI has: TakePhotoAsync and RecordVideoAsync through a full-screen modal CameraView (flip, effect strip, retake/accept review, elapsed readout, MaxDuration), PickPhotoAsync / PickPhotosAsync / PickVideoAsync through the browser’s file chooser, and app-wide compression, max-dimension and format defaults. Photos are resized and encoded in the browser and streamed into .NET, so large captures are safe on Blazor Server. Videos stay in the browser behind a playable Url until you stream them. Scan verbs ship with it: ScanBarcodeAsync / ScanBarcodesAsync return the same DetectedBarcode as MAUI, and ScanDocumentImageAsync / ScanDocumentImagesAsync return cropped pages. Shiny.Blazor.Controls.Camera.Ai adds ScanDocumentAsync(IChatClient) and typed ScanDocumentAsync<T> for cards, receipts and other documents, which browsers cannot OCR on-device. See IMediaService on Blazor.

Fixblazor
CameraView captures at HD, and a pending scan survives a lens flip. The preview asked getUserMedia for no size, so Chrome opened the camera at 640×480 and every CapturePhotoAsync was a VGA still. It now asks for an ideal 1920×1080, a preference the browser may lower. A RequestBarcodeAsync or RequestDocumentImageAsync still waiting when the camera restarted (a flip or an analyzer change) waited forever, because the restart disarmed the detector. It is now re-armed. New: RequestBarcodesAsync() returns every code in the frame, and RequestDocumentImageAsync(requireNewDocument: true) waits for the last page to leave the frame. StartRecordingAsync no longer leaves the microphone on if the camera was stopped while its permission prompt was open. See CameraView on Blazor.

Fixblazor
MediaPickerButton no longer asks twice on iPhone and iPad. Choosing “Choose from Gallery” opened WebKit’s own Photo Library / Take Photo / Choose File sheet, the same question over again — Safari and WKWebView (including MAUI Blazor Hybrid) show that sheet for every image file input and nothing on the input skips it. With both AllowGallery and AllowCamera on, the button now goes straight to the system sheet on iOS and iPadOS, which still offers the camera. Other platforms keep the button’s own chooser. See Media Picker Button.

Fixios,android,windows,macos
Typed toasts now use their theme colours, and more controls follow a theme switch live. Info, Success, Warning, Danger and Critical toasts set a literal hex colour and then the theme token on top — a local value outranks a dynamic resource, so the token never won, not even on first render. Toast colours (background, text, spinner, border) now resolve through the toast, with the hex values kept only as a fallback before a theme loads. ImageEditor’s accent buttons (crop Apply, selected stroke width, shape fill), the SkeletonView shimmer, the PickerCell checkmark accent and LoadingRing’s ring, track and text also stopped copying their colours once: they follow a light/dark flip on views already built and honour a palette scoped over the control, while explicit colours still win. IQuickEntryService.PreloadAsync() still warms the page for the first open but no longer keeps that page in memory until the popup has been shown and hidden.

Fixios,android,windows,macos
The in-app Quick Entry popup and screen glow no longer keep the last page alive. Both presenters are app-lifetime singletons and held the page they were last shown on — and the views they injected into it — until the next show. They now take their overlay off the page and let go of it once hiding finishes, including when a hide interrupts a show, the scrim is tapped, or the fade never completes because the page was navigated away from.

Fixios,android,windows,macos
TreeView, DataGrid and ChatView colours follow light/dark changes. Each copied its theme colours once — TreeView’s multi-select checkbox border and fill, DataGrid’s selection, stripe and frozen-pane backgrounds, and ChatView’s bubble text and link colours — so a flip left them in the old palette (dark text on a now-dark bubble) and a palette scoped over the control was never seen. They now track the theme tokens live: DataGrid re-runs only the row-background bindings, keeping selection and scroll position, and ChatView recolours its existing text in place. Explicit CheckBoxColor, MyTextColor and OtherTextColor still win.

Fixandroid
Camera analyzers no longer leak ML Kit detectors. The Barcode, Face, OCR and document analyzers opened Android ML Kit clients — TextRecognizer even in a field initializer — that were never closed, holding native detector memory for every analyzer instance ever created. IFrameAnalyzer gains OnAttached() / OnDetached() lifecycle hooks (default no-ops; protected virtual on FrameAnalyzer); the pipeline calls OnDetached() when an analyzer is removed, swapped, disabled or its handler disconnects, deferred past any in-flight pass, and the built-in analyzers close their clients there and re-create them lazily. IMediaService scans detach their analyzer when the modal closes, and a BarcodeScanner whose Formats change now rebuilds its client. Custom analyzers holding native resources should override OnDetached() — see Releasing native resources.

Fixios,android,windows,macos
A MediaElement that is never shown no longer leaks its player. The player was created in the constructor and disposed only on handler disconnect, while registering with process-wide sources (Android’s static Picture-in-Picture event and ExoPlayer thread, NSNotificationCenter on Apple), so a discarded or never-realized element stayed alive forever and buffered its Source. The player is now created when the handler connects, with properties, Source and an earlier Play() applied then; it is disposed on disconnect, and re-showing the element creates a fresh player instead of leaving it inert. Capabilities reads None until a handler exists. See Player lifetime.

Fixios,android,windows,macos
Memory leaks in the MAUI add-on controls fixed. The Office editors (document, slide, notebook, spreadsheet), DiagramView and CameraView no longer stay in memory after their page closes when a view model keeps the document or bound collection alive — the editor controllers, diagram collections and camera effects now subscribe weakly. Also fixed: a spreadsheet’s copy/cut marching-ants timer running after its page closed, a macOS Office canvas pixel buffer that was never freed, the Desktop file drop service holding every closed window, a screen-glow window left behind after a startup timeout, a speech-to-text session that kept the microphone open after its tool was detached, and a Skia paint allocated per pointer move while placing a floor plan element.

Fixios,android,windows,macos
Memory leaks in the core MAUI controls fixed. Controls bound to a long-lived view-model collection — DataGrid, TableView, TreeView, ChipGroup, Accordion, TagEntry, TimelineView, KanbanView, GanttView, FabMenu, FloatingToolbar, FontPicker, MediaPickerButton, the TextEntry/Prompt tools and KeyboardAccessoryView — no longer keep their page alive; they subscribe weakly, and stop listening to a collection the moment the property is reassigned. ProgressBar’s pulse and indeterminate loops, SkeletonView’s shimmer, BadgeView’s pulse and the Scheduler/Gantt “now” timers pause when the view leaves the tree and resume when it returns, instead of running until a Dispose() MAUI never calls. The toast service no longer pins the first page a toast was shown on (toasts now follow navigation to the current page), the progress-line service releases the last page it drew on, the toast and dialog managers no longer hold closed windows, iOS drag handles no longer form a native reference cycle, ChatView’s iOS keyboard observers are removed on unload, and repeated taps on keep-selected cells no longer pile up page Appearing handlers.

Fixios,android,windows,macos
ShinyTabs.Actions on a ShellContent now drives the Shell centre button. ShinyTabBarBehavior only ever handed the bar the current page, so actions and menu content declared on a ShellContent were ignored and the button dimmed. The menu now resolves from the page, then its ShellContent, Tab and ShellItem; a page’s rows replace the ShellContent’s, ShellContent rows bind against its context, and the button follows collection edits and tab switches live. See Shell.

Fixios,android,windows,macos
DataGrid Auto columns now line up with their header. The header, each row and the footer were separate Grids that each sized an Auto column to their own content. The grid now measures the header, footer and the first AutoWidthSampleSize items (new, default 100) and gives every row that one width, re-measuring when the columns or source change but not on sort or paging. See Column widths.

Fixios,android,windows,macos
ImageEditor ribbon icons now follow the theme. The icons copied the icon ink once when the ribbon was built, so a light/dark flip or a palette scoped over the editor left dark icons on a dark ribbon. They now follow the OnSurfaceVariant token live through a shared themed icon view, which the Office ribbons use too.

Fixblazor
A pinned Office theme now themes the chrome, not just the canvas. Theme="SpreadsheetTheme.Dark" on a light page painted a dark grid under a light ribbon, pickers, formula bar and sheet tabs, because that chrome follows the --shiny-color-* tokens and nothing scoped them. SpreadsheetView, DocumentEditorView, SlideEditorView and NotebookEditorView now put the matching shiny-theme-dark / shiny-theme-light class on their root while pinned, and drop it when unpinned.

Fixblazor
Components no longer leak when disposed before their JS module loads. Removing a component while its script was still importing — a quick route change or an @if toggle — still initialised it afterwards, leaving a DotNetObjectReference to the component and often window/document listeners behind, so it never left memory. Around 30 components are fixed (sliders, SheetView, Carousel, ModalView, Tooltip, Walkthrough, DockHost, KanbanView, GanttView, AppLayout, OnScreenKeyboardHost and more), plus ThemeSchemeWatcher and the file drop service. ChatView no longer subscribes to, or starts a typing timer against, a session that finishes opening after the view is gone.

Fixblazor
Memory and device leaks fixed across the Blazor add-on packages. The document, slide and notebook editors no longer keep their removed DOM alive through JS handles that were never released. CameraView releases the camera even when disposed while the permission prompt is open, stops a recording’s microphone and removes its SVG filter definitions. MediaElement clears the page-wide media session handlers that kept a removed video alive. The speech-to-text button is now actually disposed, so removing it stops listening, and a second button no longer strands the first recognizer. PromptTextToSpeechTool releases its JS module on detach. DiagramView, CameraView and MediaElement no longer attach browser listeners when disposed mid-import. The shared watermark image cache is capped instead of growing for the life of the process (which on Blazor Server spans every user), FloorPlan disposes its per-frame dash effects, and MermaidDiagram cleans up after failed renders.

Fixios,android,windows,macos,blazor
Dark mode no longer turns a table header’s text white on its light fill in DocumentView and DocumentEditor. The painter lifted ink for contrast against the page rather than against what the text actually sits on, so a light cell shading in dark mode got near-white text — and, the other way round, white header text on a dark fill was pulled to grey in light mode. Ink now resolves against the nearest backdrop (cell or paragraph shading, a highlight, a solid shape fill, otherwise the page) and authored fills are kept. The spreadsheet grid had the same fault for automatic-colour text on a filled cell, and is fixed too. See Document Viewer.

Fixios,android,windows,macos
The SpreadsheetView ribbon and formula bar now follow dark mode along with the grid. The Office views’ theme-change handler was only weakly held, so after the first garbage collection a light/dark flip stopped reaching the formula bar, sheet tabs and grid, leaving white text on a light formula bar. The handler now lives as long as its control (FloorPlanView had the same bug and is fixed as well). A pinned Theme also carries through now: the formula bar takes its ink from the theme, the toolbar scopes the app pack’s matching light or dark palette so the ribbon and its pickers match the grid, and the ribbon re-colours its tab labels when that palette changes. The palette is written into the toolbar’s own resources rather than merged in, because MAUI ignores merged dictionaries when it filters inherited resource changes — merged, the app’s light/dark flip leaked through a pinned toolbar, and unpinning left the ribbon dark. Office ribbon icons also follow the theme now: they copied the app’s ink once when built, so a pinned or flipped theme drew them dark-on-dark. See Spreadsheet.

Fixios,android,windows,macos,blazor
KanbanView collapsed columns show their full count and no longer draw empty lanes. A collapsed 52-wide header kept the expanded title | count | chevron row, which left the count about two units of width; it wrapped, and only “/ 4” survived. The collapsed header now stacks the chevron over a compact count that never wraps (3/4), and a collapsed column’s lanes paint nothing while still accepting drops. The Blazor KanbanView gets the same collapsed header and lane styling. See Kanban.

Fixios,android,windows,macos
ShinyTabbedPage and the Shell tab bar never open an empty centre menu. A tap already fell back to a plain click on a tab with nothing to present, but OpenMenu() and IsMenuOpen = true skipped that check and drew an empty card. Every route to opening the menu is now gated, and an open menu closes when its last action is removed. When the fallback click would also do nothing (no Command, no CenterClicked handler), the centre button dims like a disabled one, re-evaluating as tabs change and as the page’s ShinyTabs.Actions change. See TabbedPage.

Fixios,android,windows,macos,blazor
PasswordStrength’s warning now follows the Localizer. The built-in warnings (e.g. "pass" is a very common password) were plain English strings that the control showed as-is, so a French localizer translated the checklist but not the warning. The evaluator now tags each warning with PasswordStrengthResult.WarningKey (WarningCompromised, WarningBlocked, WarningUserInput, WarningCommonPassword) and WarningValue, and the control runs it through Localizer with the matched word in the new PasswordStrengthText.Value. A custom evaluator’s warning without a key is still shown exactly as written. See PasswordStrength.

Fixios,android,windows,macos
DataGrid column headers no longer shrink to “…” in narrow columns. Each header gave a fixed 40% of its width to the filter/group/reorder glyphs even when it held a single ▾, so on phone-width grids the titles were cut down to an ellipsis. The glyphs now take only the width they need and the title gets the rest; in a column too narrow for both, the title keeps 60%. The grouping sample now scrolls sideways with a frozen first column instead of fitting five columns onto a phone.

Fixmacos
Docking tab titles are no longer clipped on macOS. The tab strip relied on its horizontal ScrollView to report the tabs’ height, but the AppKit ScrollView handler reports the native scroll view’s fitting size instead, so the strip’s auto row collapsed and cut off the top and bottom of every title. The strip and each tab now reserve a minimum height derived from their label font sizes plus padding, so the strip is tall enough on every head.

Fixios,android,windows,macos
A page-docked ProgressLine never appeared, and the service-driven line landed in the wrong place. Three separate faults. XAML makes every direct child of a ContentPage its Content in turn, so a line declared next to the page’s layout was the content for a moment and then replaced by it — the dock is deferred a tick, and by the time it ran the line had no parent and no way back to its page, so it was never added to the tree and nothing reported it. The line now remembers its page and re-docks when the content it sat in is swapped. Separately, the inset rule did not recognise Shell’s native navigation bar, so on a Shell page the IProgressLineService line added the status-bar safe area on top of a bar MAUI had already excluded and drew about 62pt too low; Shell’s nav bar, and its bottom tabs when an item has more than one section, now resolve to zero like NavigationPage and TabbedPage. And the overlay layer the line docks into was a Grid applying its default safe area, so a bottom line counted the home indicator twice and floated above the edge; the layer is now edge-to-edge. See ProgressLine.

Featureios,android,windows,macos,blazor
ProgressBar can draw steps instead of a solid bar. Set Segments to split the track into that many separate pieces with a SegmentSpacing gap between them — the stepped look of a delivery tracker or a multi-stage upload. The fill still runs as one value across the steps, so a value part-way through a step lights it partially, and the slide animation travels step by step through the gaps rather than every step animating at once; line Maximum up with the segment count for whole steps. Track, bar colour and corner radius apply per step, and a gradient is spread across the run with each step taking the colour at its centre — on MAUI that keeps every step a solid colour, which the AppKit BoxView can actually paint. Indeterminate mode and the pulse sheen keep using the continuous bar. See ProgressBar.

Featureios,android,windows,macos,blazor
KanbanView — a board of columns you drag cards between, on both hosts. WIP limits that are actually enforced, swimlanes, collapsible columns, a cancellable move and an inline add-card composer. Every decision lives in KanbanBoard — which lane a card is in, whether a column is over its limit, whether a drop is allowed, where exactly it lands and what that does to every other card’s order — and that file is mirrored line for line into both packages, with a test that fails if the copies drift. A drop that behaved one way on a phone and another way in a browser is a bug neither host’s own tests would ever notice, so the arrangement removes the possibility rather than testing for it. A WipLimit counts the whole column across every swimlane, because a limit is a limit on work in that state, not on work in that state on one row of the board; reordering inside a full column stays allowed, since sorting work already in progress adds none. ShowWipLimits and WipBehavior are deliberately separate, so a board can shout without refusing or refuse without shouting, and a refused drop raises DropRejected with a reason rather than doing nothing — “I dragged it and nothing happened” being the single most common complaint about a Kanban. The board never creates a card: AddCardMode raises a request and you add to your own collection. MAUI drags on a pan gesture (the platform drag recognizers are broken on Mac Catalyst, missing on AppKit and GTK4, and carry no pointer position anywhere), Blazor on pointer events (HTML5 drag-and-drop never fires on touch, and a board is the control people most expect to drag on a phone). See Kanban.

Fixblazor
A photo picked with MediaPickerButton was carried into .NET as one base64 string, which on Blazor Server was big enough to kill the connection. Every picked image was encoded to base64 and sent as a single JS interop message: a 4MB photo became a 5.4MB string, built in full before a byte could move, and SignalR’s default MaximumReceiveMessageSize is 32KB — so the server closed the circuit and the upload never happened, with nothing in the browser console but a disconnect. The bytes now stay in the browser. What crosses into .NET is a descriptor (id, an object URL for <img src>, dimensions, content type, size), and the bytes move as binary when something asks for them: MediaPickerItem.Data is filled from a chunked IJSStreamReference on pick, OpenReadStreamAsync/ReadAllBytesAsync fetch on demand, and an edit goes back to the browser as a DotNetStreamReference. Nothing is base64-encoded on any path.

Featureblazor
MediaPickerButton can upload photos itself, straight from the browser. Set UploadUrl to a MediaPickerUpload — address, form field, method, headers, credentials — and the browser posts each photo as multipart form data without the bytes ever entering .NET. UploadAllAsync(upload) takes an address that is only known after saving, which is the usual shape: save the record, then upload against the id it was given. AutoUpload sends each photo as it is picked, UploadProgress reports real per-photo progress from the browser’s own upload events, and Uploaded hands back the server’s status and body verbatim. A failed photo stays in the picker so “try again” costs the user nothing, and keep: true sends the same photos to more than one place. Never hand a token to the browser for this: post to your own origin with a short-lived ticket in Headers and let the server attach the real credential. See MediaPickerButton.

Fixblazor
A SheetView closed by tapping the backdrop or swiping it down stayed on screen, blank. Both routes run through the sheet’s own OnOpenChanged, which recorded the new state on the component and on its IsOpen parameter before telling the parent — so when the parent handed the same value back, OnParametersSetAsync saw nothing to do and sheet.js was never asked to move the sheet. It sat at its detent with the backdrop still dimmed, while the parent had already let go of whatever it was showing, which is what emptied it. The sheet is now moved where the decision is made, whoever made it, and a close from inside still reaches JS exactly once.

Featureios,android,windows,macos,blazor
RibbonRow — the missing half of the ribbon’s layout language. A group fills columns: small items stack down one until it is full, then the next starts. That is right for a set of interchangeable commands and wrong for a run. Bold, italic, underline and strikethrough are read left to right, and a two-row column turns four of them into a 2×2 block with underline above italic. Put RibbonRows in a group and it lays them out as rows instead, which is what Office actually draws. There is no mode to set — the presence of a row is the signal, and a group is one or the other. See Ribbon.

Fixios,android,windows,macos,blazor
The Office ribbons have been re-laid out, and most of what was wrong with them was this. A font-name box and a bold button were sharing a column, so the column took the box’s width and the 16px B was drawn stretched across all 150px of it — the “button that spans two columns with only an icon in it”. The four marks read as a 2×2 block. Three-item groups (Clipboard, Proofing, Editing) left a visible hole where the third row would be. The zoom stepper had its minus and plus on different lines with the readout wedged between them. Word’s, Excel’s and PowerPoint’s own arrangements are back: the two boxes on top and the run of marks underneath, vertical alignment on one row and horizontal on the other, named formats over the four that nudge them.

Fixblazor
The watermark button drew a native “Choose File / No file chosen” control in the middle of the ribbon. It is a <label> wrapping an InputFile, and the class that makes that input invisible was only ever declared in OfficeInsertMenu.razor.css — Blazor scopes a stylesheet to the markup in its own file, so the rule compiled to .shiny-office-btn-file[insert-menu] and could never reach a label another component rendered. It looked like a control that had been dropped into the wrong group; it was a control with no CSS. It now has its own stylesheet, and on the slide editor it has also moved out of Paragraph — a picture drawn behind the whole slide is not a paragraph command — into a Design group.

Fixblazor
The notebook’s toolbar buttons were unstyled for the same reason. NotebookEditorView never restated the ::deep .shiny-office-btn rules the document, slide and spreadsheet views each carry, so the highlight picker rendered as a browser-default button — a solid slab beside 16px glyphs, with its chevron as a second slab next to it.

ios,android,windows,macos,blazor
Primary commands are large and labelled now. Paste, AutoSum, Spelling, Width, Page break, Header/Footer, Table/Picture: a bar of nothing but 16px marks has no shape to recognise, and “check spelling” is not something anyone reads off a glyph. Each group that had three icon-only items now has a head and a stack of two beside it, which is the shape three items want. The colour pickers are pinned to the row height as well — a solid swatch is the heaviest mark on a bar, and at the picker’s own 30px beside 16px glyphs it read as the one thing that had gone wrong.

Featureios,android,windows,macos,blazor
Theming has an authoring layer: ~35 values you edit, and a contract derived from them. The 55 Material colour roles plus the shape, type, spacing, density and elevation ramps were the only thing a theme could state, which meant 153 flat tokens per scheme and a generated DO NOT EDIT stylesheet as the only way in. They are now derived from a small set with names every design system uses — background, foreground, card, popover, muted, primary/secondary/accent/destructive/success/info/warning/caution/critical with their foregrounds, border, input, ring — plus the knobs: radius, density, text-scale, border-width, the font stacks, and the weight/tracking/line-height offsets. See Theming.

Featureblazor
You can now restyle the whole control set from your own stylesheet. The derived roles are written as var() and color-mix() over the authoring tokens, so one override ripples: set --shiny-primary and the primary container, the surface tint and the focus ring move with it; set --shiny-radius and every corner in the ramp follows; set --shiny-density and the control heights and spacing scale do. No JSON, no generator, no package — a :root { } block in your own CSS, which is what a web developer would have tried first.

blazor
A theme pack is a delta now, not a second copy of everything. Each pack used to be a complete 364-line re-declaration of all 153 tokens that happened to differ in its colours; they now restate their palette, their knobs, and only the few values that cannot be expressed over them — an outline-style elevation, a squared-off corner-full. Terminal is the extreme case and it is 194 lines, most of it palette. Nothing about linking a pack changed.

ios,android,windows,macos
The container tints moved slightly, and they are more readable for it. MAUI has no color-mix at paint time, so the same derivations are computed when the theme is generated — in oklab, the space the CSS mixes in, so the two hosts land on the same colour. A container is now the accent laid over the page rather than a fixed Material tone, which is what lets it follow a changed brand colour. Every ink/ground pair in all five packs clears WCAG AA in both schemes, and a new ThemeContrastTests suite holds them there.

Fixblazor
shiny-theme-dark on a container, rather than on <html>, now actually themes that container. A custom property is substituted where it is declared, and the resolved value is what inherits — so a contract declared only on :root resolved once against the light palette and went on inheriting it into a dark subtree. The scoping classes are named alongside :root on the derived block, so every scope that carries a palette re-derives from its own.

Fixios,android,windows,macos
A ShinyButton that changed appearance at runtime could lose its fill. Going from Outlined or Text — which paint a transparent background as a local value — to Filled, Tonal or Elevated wrote the new colour as a dynamic resource, and a local value outranks one: the token resolved, was quietly outranked, and the button stayed transparent. With OnPrimary text on it, the result was an invisible button, which is how it surfaced — a segmented ButtonGroup whose selected segment was a gap with white text on white. Every colour the button applies (surface, outline, label, glyph, spinner and motion icon) now goes through the same clear-then-set the rest of the library uses, so an appearance, type or colour that changes after the first render actually takes.

Featureblazor
The Theme Composer replaces the Theme Creator, and it drives real controls. The old creator was a React island on the docs site that re-implemented the C# generator in TypeScript: it had drifted from themes/*.json in three of the five packs, shipped three of the five, and previewed hand-built mock-ups rather than a single actual control. The composer is a page in the Blazor playground, framed by the docs — so its canvas is the library and cannot drift from it. Knobs for pack, accent, radius, density, type scale, font and weight; the whole selection round-trips through a ?preset= code short enough to paste into a review; Get code exports both the Blazor custom properties and the MAUI ResourceDictionary. See Theme Composer.

Fixblazor
Component CSS no longer reads like six different design systems when the theme stylesheet is absent. Every var(--shiny-color-…, literal) in the library carried a hand-written fallback, and they had diverged badly — 892 of them, with --shiny-color-on-surface-variant alone appearing as #6b7280, #666, #6B7280, #9CA3AF and #374151 depending on which control you happened to be reading. A fallback only paints when the consumer has not linked shiny-theme.css, so every one of those was invisible in a themed app and load-bearing in an unthemed one. All 891 now carry that token’s own value from the Basic light theme, and a scan asserts it stays that way.

Fixblazor
An app stylesheet can now beat a control’s own defaults. Sixty-two styling parameters shipped their defaults as inline styles — style="background: var(--shiny-color-primary)" — and an inline declaration outranks any selector, so a rule like .shiny-pb-fill { background: … } in an app’s CSS was dead on arrival and the “default” was a permanent decision. Each default now also lives in the component’s own stylesheet, and the inline declaration is emitted only where the caller actually passed something. Nothing changes for a caller who sets the parameter; what changes is that leaving it alone finally means leave it to CSS. Two colours that had quietly disagreed between the parameter and the stylesheet — the skeleton shimmer and the timeline rail — were reconciled in the process.

Featureios,android,windows,macos,blazor
ButtonGroup — related buttons joined into one segmented control. Inner corners square off, adjacent outlines collapse into a single edge, and the whole thing runs across or down. It is a container for actions first — every segment keeps its own Command/Clicked — with ButtonGroupSeparator for a split button, ButtonGroupText for a static label or unit, and nested groups reading as one spaced cluster rather than one very long control. See ButtonGroup.

Featureios,android,windows,macos,blazor
The same markup is also a segmented picker. SelectionMode is None by default and stays out of the way entirely; set it to Single or Multiple and the group grows a two-way SelectedIndex, SelectedIndexes, AllowDeselect and SelectionChanged. While a selection mode is on the group owns each segment’s Appearance — except where the segment set one itself, which wins as the unselected look, so one odd segment in a picker stays odd, and leaving the mode hands every appearance back. Only buttons are selectable: counting a separator would make the indexes a view model is bound to depend on where a divider happens to sit.

ios,android,windows,macos
The corners are resolved as numbers, not as a token. A segment’s inner corners have to be zeroed, and two corners of a value that has not resolved yet cannot be. So the MAUI group reads the theme’s corner radius and hairline thickness off a hidden probe inside its own resource chain and pushes explicit geometry onto each segment — which is what keeps a segmented group correct across a live ShinyThemeManager.SetTheme rather than freezing it at startup. The edge collapse is a negative margin, applied only to segments that actually paint an outline: a filled segment has no edge to double up, and pulling it into its neighbour would overlap the two fills.

Featureblazor
On Blazor the joining is CSS, and mirrors under RTL for free. :first-child/:last-child with logical corner properties, reached through ::deep, so it works for any segment — a button, a text segment, a separator, a nested group — without the container counting its children, and the ends swap under a right-to-left reading direction with no second rule. A segment of a selection group renders aria-pressed; an ordinary action button renders none. One thing to know: a per-button CornerRadius is written inline and keeps its own rounding inside a group.

Featureios,android,windows,macos,blazor
TagEntry — a free-text tags field. Type and press Enter, or a delimiter, and the value commits as a removable chip in one bordered box. Pasting "a, b, c" commits three tags while typing the same characters leaves c in the editor, because the user is still writing it — the rule is the size of the insertion. Delimiters defaults to a comma and an empty list means Enter-only, which is how a tag gets to contain one. MaxTags caps the count and leaves the typed text alone rather than eating something the user wrote, duplicates are dropped quietly (AllowDuplicates, CaseSensitiveDuplicates), and ChipTemplate/ChipContent replaces a chip’s label but never its remove button. It is deliberately not a suggestion list — when the values come from a known list, that is AutoCompleteEntry. See TagEntry.

ios,android,windows,macos
Backspace-on-empty needed a trick on MAUI, and it is documented rather than hidden. There is no portable key-down on an Entry: backspacing text that is already empty changes nothing and raises nothing, so there is no event to listen to. The field therefore keeps a zero-width space in front of whatever is typed, and reads its deletion as the Backspace. It costs the placeholder — a prompt does not show while any text is present, invisible or not — so it is in the box only while it can do something: while the field is being typed into, and while there is a tag for Backspace to reach. The sentinel never reaches a committed tag; BackspaceRemovesTag="False" turns the whole mechanism off if a platform’s predictive text fights it, and nothing else about the control changes. Blazor has a real keydown and needs none of it.

ios,android,windows,macos,blazor
Validation is a seam, not a rejection message. TagAdding is cancellable on MAUI and TagValidator is a predicate on Blazor, and both run after trimming and after the duplicate and cap rules — so a handler only ever sees a tag that would otherwise have been added. A refusal leaves the typed text in the editor to be corrected rather than retyped.

Featureios,android,windows,macos,blazor
ChipGroup — a bound set of chips, and its selection is the items themselves. One chip per item in ItemsSource, selectable singly or in any number, reported back through a two-way SelectedItem and SelectedItems. That is the whole difference from ButtonGroup: a button group’s segments are written out in markup and its selection is an index, which is the right shape for Day / Week / Month and the wrong one for a list that arrives from a service — a view model would have to translate indexes back into the objects it already has. It also wraps, so a dozen filters flow onto as many lines as they need instead of becoming one unreadably long segmented control. See ChipGroup.

ios,android,windows,macos,blazor
SelectionMode defaults to Single here, and that is deliberate. ButtonGroup defaults to None because it is a row of actions first; selection is the reason a chip group exists, so it starts on. Multiple gives filter chips with an optional MaxSelectionCount — at the cap a tap on an unselected chip does nothing rather than dropping the oldest pick, because silently unpicking something the user chose is worse than refusing the new one. None is still worth having: every chip reports ChipTapped and nothing is ever painted as selected, which is an action-chip row. AllowDeselect (off) lets a second tap clear a single-selection group. Both SelectedItem and SelectedItems are kept accurate in every mode, so a view model can read either.

ios,android,windows,macos
The bound SelectedItems list is written into, not replaced. Handing back a fresh instance is easier and breaks every view model that holds its own ObservableCollection and watches it — so the MAUI group clears and refills the list it was given, and leaves a read-only or fixed-size one (an array) alone rather than throwing over a binding that is merely a snapshot. A selection the source stops offering is dropped on the next rebuild: a selection with no chip to show for it is one the user can neither see nor undo.

ios,android,windows,macos,blazor
What a chip says is a binding, not reflection. ItemDisplayBinding takes a whole BindingBase and DisplayMemberPath names a property for the common case; the path is compiled either way, so it survives trimming and a converter or StringFormat comes along free. MAUI applies the consumer’s binding to a single off-tree probe and pushes each item through it, because MAUI refuses to apply one BindingBase instance to two targets. Blazor takes a DisplaySelector instead. ItemTemplate/ChipContent replaces a chip’s label but never its selection check or its ✕ — every chip keeps the same state and the same way out, however it is drawn.

blazor
On Blazor a chip is a <span> with two sibling <button>s inside it. A button inside a button is invalid HTML and the browser un-nests it, which would put the remove control outside the chip it belongs to — so the span carries the visuals and the selectable body and the ✕ sit in it side by side. A selection chip renders aria-pressed; an action chip in None renders none, because claiming a toggle state that never changes would announce one to a screen reader anyway. One thing to know: unmatched attributes land on the container but class and style stay the component’s, or a caller’s style would silently replace the element’s own CSS custom properties and the group would stop following the theme — CssClass is the way to add to the class list.

Featureios
ShinyTabBar can sit on real Apple Liquid Glass — BarMaterial="Glass". A genuine UIGlassEffect behind the bar rather than a blur that approximates one, in Glass (the weight the system uses for its own bars) and GlassClear, with BarGlassTint to wash it in a colour — point that at a theme token and the glass follows your palette instead of the system’s. A floating bar becomes a capsule natively, so its ends stay semicircles as it resizes rather than being recomputed from the height. It is iOS 26 only: on an older iOS, on Android, Windows and GTK4, and on Mac Catalyst and macOS AppKit (for which the core package has no native build yet), the property is remembered and the bar keeps painting the fill it always did — the same XAML runs on every head, and a bar declared as glass is never a bar that failed to draw. See TabbedPage.

ios
Glass takes the background over, rather than sitting behind it. BarBackgroundColor and BarBackgroundOpacity stop being painted, because anything painted there sits between the glass and the page and leaves nothing to refract. HasShadow is ignored: glass carries its own edge shading, and a Material drop shadow under it reads as a sticker laid on the page rather than as depth. And the content is let under the bar for you, exactly as BarStyle="Floating" already does — glass with nothing behind it is an expensive way to draw a grey rectangle. Two things Apple’s own bar does are deliberately not here yet: the glass is not interactive (that effect keys off touches delivered to the effect view, and every gesture in the bar belongs to the MAUI views above it), and a centre button does not merge with the bar the way UIGlassContainerEffect merges neighbouring panes.

Fixios,android,windows,macos
HasShadow="False" did nothing on ShinyTabBar, its centre button, or ShinyNavBar. The shadow is bound to an elevation token with SetDynamicResource, and ClearValue — which reads exactly like the way to undo that — does not clear a value that arrived from a dynamic resource: the resolved Shadow was still sitting there afterwards, so a bar told to stop casting one went on casting it. Removing the dynamic resource only stops the next theme swap re-applying it; the explicit null is the only thing that actually takes the shadow off, and that null is then a local value that would outrank the dynamic resource when the shadow is switched back on. Both halves now live in WithElevation/WithoutElevation, so the switch works in both directions and still follows a theme swap.

Featureios,android,windows,macos
Any toolbar item on a ShinyNavigationPage can carry a badge, not just a NavBarItem. ShinyNav.Badge and ShinyNav.BadgeColor are attached properties, so a page’s own Page.ToolbarItems — plain ToolbarItems with no badge of their own — get one without being rewritten as NavBarItems: <ToolbarItem Text="Drafts" shiny:ShinyNav.Badge="{Binding DraftCount}" />. The rules are unchanged ("" is a dot, null is nothing, an unset colour follows the theme’s error colour), they are bindable like any other attached property, and setting one on a NavBarItem is allowed too — the item’s own Badge wins while it is non-null, so attaching one never silently overrides what the item already says. See NavigationPage.

Fixios,android,windows,macos
A badge no longer disappears when its item folds into the overflow menu. The menu row drew the badge as plain grey text — losing BadgeColor on the way in, ignoring a dot badge entirely, and sitting over the end of a long row’s title — and the overflow button itself said nothing at all, so a count on a Secondary item was invisible until the menu was opened. The row now draws the same badge pill the bar does, in its own column, and the overflow button carries a dot while anything behind it is badged. A dot rather than a total: the button stands for several items, and summing badges that may not all be numbers would invent a number nobody asked for.

Fixios,android,windows,macos
A MAUI ZoomPanView scaled itself rather than its content, so zooming drew nothing. The transform went on the control, which meant its layout slot scaled with it — at 2x on iOS the box came back blank. The content now sits on an inner surface that carries the transform while the control keeps its size, which is the arrangement the Blazor half always had. Found on a device pass; headless tests could not see it.

Fixios,android,windows,macos
A FloatingToolbar that changed shape while open kept the old one. Placing writes an explicit rect, and that rect then dictates the bar’s size — so the measurement was circular and the bar could never re-measure to fit new content. Switching to Vertical kept the one-row height and clipped every item but the first; switching back kept the narrow column and clipped the row. The bar is now handed back its natural size on a rebuild and re-placed once the new measurement arrives, and the strip’s chevron points down on a bar running across and right on one running down.

Fixios,android,windows,macos
Trim/AOT: the theme probe behind every token-coloured Brush no longer uses a reflection binding. ThemeProbe bound its brush to the hidden probe’s colour with a string-path Binding, which raised IL2026 and, under full trimming, could resolve nothing — leaving outlines and fills stuck on their transparent seed instead of following the theme. It is now a source-generated typed binding, so the path is compiled rather than looked up at runtime.

ios
Known gap: a zoomed ZoomPanView is not clipped to its bounds on iOS. The zoom is correct and the content stays interactive, but anything scaled past the control’s box paints over the surrounding layout rather than being cut off. IsClippedToBounds, an explicit Clip geometry, an inner clipping Layout and an enclosing Border were all tried and none holds a scaled child in on that platform. Blazor clips correctly. Give the control room, or keep MaxZoom modest, until this is resolved. See ZoomPanView.

Featureios,android,windows,macos,blazor
FloatingToolbar — a toolbar that floats over a control, the way a tooltip does. Icons, labels, badges, dropdown menus with submenus, and an overflow, laid out horizontally or vertically, anchored to whichever control triggered it. Every trigger the tooltip has works here: Manual, Tap/Click, LongPress, Hover, Focus, plus HoverOrFocus on Blazor. This is not the docked page bar — ShinyToolbar is still that; reach for this one when the bar belongs to a control rather than to the page. See FloatingToolbar.

ios,android,windows,macos,blazor
It is the tooltip’s anchoring, not a second copy of it. MAUI’s trigger wiring came out of Tooltip into a shared AnchorTriggerBinder — every awkward detail of anchoring to an arbitrary view lives there now, including the Button and ImageButton that consume touch natively and never route it to their gesture recognizers, and the long press that cannot be timed from a pan because a pan does not begin until the finger has already moved. The side the bar lands on comes from the same TooltipPlacementSolver that was already shared with Walkthrough, and on Blazor the placement, the top layer and the reposition-on-scroll watcher all come from tooltip.js. Two implementations of “put this next to that” would only have drifted.

ios,android,windows,macos,blazor
One bar can serve a whole list. A toolbar per row means every row carries a live control that is almost never on screen; instead one instance re-anchors to whichever row was triggered. On MAUI that is the attached FloatingToolbar.AttachTo, and the click args carry the target’s BindingContext — the row itself. On Blazor Target is a CSS selector, so one matching many elements binds all of them and the args carry the index. Triggering a different row while the bar is open re-anchors rather than closing: the second row’s tap would otherwise read as “close”, and the bar would flicker off the very row it was asked for.

ios,android,windows,macos,blazor
HideDelay is what makes a hover toolbar usable, and it is on by default. The pointer has to cross the gap between the target and the buttons to reach any of them, and a bar that closed on pointer-exit would be gone before it got there — visible, and permanently unclickable. Leaving the target schedules the close at 250 ms; arriving on the bar cancels it. Overflow folds whatever does not fit into a ⋯ dropdown, and the cap counts that button: a cap of three draws two real items and the overflow, because adding the overflow cell to three would put the bar straight back over the width that caused it.

Featureios,android,windows,macos,blazor
FloatingToolbar animates in and out, with the tooltip’s own vocabulary. Animation is TooltipAnimation — None, Fade, Scale, Slide — rather than a second enum meaning the same four things, with AnimationLength for the duration and zero to snap. Scale and Slide are directional: they grow out of, or travel away from, the edge nearest the target, and the side used is the one the placer actually chose rather than the one asked for, so a bar that had no room above and flipped below has its entry flip with it. MAUI’s animation code came out of Tooltip into a shared AnchoredPopoverAnimator at the same time, so the two controls now move identically instead of approximately. On Blazor the exit is a real animation rather than a removal — the bar is returned to its start state and held there for the duration before it leaves the DOM, because an element removed outright has nothing left to animate.

Featureios,android,windows,macos,blazor
ZoomPanView — pinch, pan and double-tap zoom over any content, on both hosts. A layout, a chart, a form, a table: whatever you put inside it zooms. This is ImageViewer’s zoom machinery with the picture taken out, and it is the same machinery — one ZoomPanController on MAUI and one zoom-pan-core.js on Blazor now drive both controls, so the pan limits and the gesture arbitration stopped being two implementations that could drift. Reach for ImageViewer when the content is a picture and you want a full-screen lightbox, and for this when you want an inline surface that stays where you put it and clips to its own bounds. MinZoom/MaxZoom, a two-way ZoomLevel, IsZoomed, IsZoomEnabled, DoubleTapToZoom/DoubleTapZoom, AnimationLength (zero snaps), plus ResetZoomAsync()/ZoomToAsync() and a ZoomChanged event. See ZoomPanView.

ios,android,windows,macos,blazor
The content inside stays interactive, and that is a design decision rather than a happy accident. Zoom is a render transform, so nothing re-flows and no measure pass runs mid-pinch — but a pan gesture on a container competes with everything inside it, which is what usually makes a zoomable container a dead one. So the pan recognizer is attached only while the content is actually zoomed: at rest the content owns its gestures outright. On Blazor touch-action is claimed on exactly the same terms, or a ZoomPanView halfway down a long page would take scrolling away from it; a gesture that starts on a button, link or input is left to that control until the surface is zoomed; and a pan takes the pointer capture only once the pointer has travelled far enough to be a drag, because capturing on pointerdown swallows the click on everything inside.

blazor
Blazor adds wheel zoom; MAUI cannot. WheelMode is Modifier by default — Ctrl or ⌘ plus the wheel, which is also what a trackpad pinch already sends — with Always and Disabled either side of it. A plain wheel belongs to the page’s scrollbar, which is why claiming it is opt-in. MAUI has no equivalent cross-platform hook for a scroll wheel over a view, so on a desktop MAUI head the way in is ZoomToAsync or the two-way ZoomLevel.

Fixios,android,windows,macos
A MinZoom below 1 would have thrown out of ImageViewer’s old clamp. The pan limit is half the overhang the scale creates, which goes negative below natural size — and Math.Clamp reads a negative maximum as min > max and throws. The viewer never hit it because its floor was hard-coded to 1; the shared controller takes a floor from the caller, so the limit is now caught before it gets there and the content simply sits centred with nothing to pan.

Featureblazor
ModalView gained ContentMaxHeight — a cap on the scrolling region rather than on the panel. MaxHeight has always capped the whole panel, which leaves you doing arithmetic over the header and the footer to work out what the body will actually get. ContentMaxHeight caps the body directly: the panel shrink-wraps its content up to that point and starts scrolling there, with the header and footer keeping their own height either way. It scrolls whatever ScrollBody says, because a capped region that does not scroll only hides the rest of the content. See Modal.

Fixblazor
A ModalView window can no longer be resized or dragged until its header and footer are off screen. The resize grip clamped the panel to the viewport, but the panel sits inside a layer that keeps a gutter around it — so pulling the grip to the bottom of the screen made the panel taller than the space it had, pushed the header off the top and clipped the footer, taking the buttons that answer the modal with it. The grip now stops at the layer’s own box, read off the layer rather than assumed, so it narrows with the phone breakpoint and goes to zero when the panel is maximised. The drag clamp kept only a 40px strip on screen, which was enough to drag the panel back but not enough to reach anything on it; it now keeps the whole panel inside the viewport. A panel taller than the viewport is the one exception — there the two limits invert and the clamp becomes a range between the panel’s own edges, which is how the rest of an oversized window is read. Growing a panel that has already been dragged re-clamps the offset, and shrinking the browser window now pulls an overhanging panel back inside it instead of leaving it stranded.

Fixblazor
ScrollBody="false" now does what it says. It was documented as letting the panel grow instead of scrolling the body, but the panel’s cap stayed on, so the body simply overflowed a panel that could not grow and everything past the fold — including the footer’s buttons — was clipped away with no scrollbar anywhere to reach it. The panel is now genuinely uncapped in that mode and the layer scrolls behind it. Centring there is done with auto margins rather than flex alignment, which is what makes the top of an oversized panel reachable at all, and the backdrop is pinned so it does not scroll away with the panel.

Featureios,android,windows,macos
ShinyTabBar gained a transparency setting — BarBackgroundOpacity. It fades the background only: icons, labels, badges and the indicator stay fully opaque, because a bar whose tabs fade along with it is not a translucent bar, it is a faded one and it is unreadable long before the background gets interesting. The alpha therefore goes on the colour, never on a view — Opacity on the surface takes everything inside it down too, and opacity multiplies down the tree so a child cannot undo it. It multiplies into any alpha the colour already carried and follows a theme swap. Pair it with ContentBehindTabBar, or BarStyle="Floating" which implies it, or there is nothing behind the glass to see. See TabbedPage.

Fixios,android
Changing tabs now closes an open menu. Both the centre button’s menu and the overflow tab’s stayed on screen over the page they were opened from. The close sits where the selection actually completes, so it holds however the tab changed — a tap, the overflow menu, GoTo, or a binding on SelectedIndex — while a reselect correctly leaves the menu alone, since the page underneath it has not changed.

Fixios,android
A docked ShinyTabbedPage’s bar now reaches the bottom of the screen, with its tabs above the home indicator. Three separate things were in the way. ShinyTabbedPage’s root grid is a Grid, which defaults to SafeAreaRegions.Container and insets the area its children are arranged into — so it handed the bar a row that stopped 34pt short and nothing the bar did could get past it. SafeAreaEdges then turns out not to reach a view laid out inside a chain of Auto rows at all: declared there the inset arrives as zero, which is indistinguishable from a device with no home indicator, so the docked bar now reads the window’s inset and pads it into its surface, with the surface’s height becoming BarHeight + inset — extra height rather than a squeeze. And once the background genuinely did reach the bottom, the surface’s own Container fired for the first time and took the inset out a second time: the tab row collapsed to 16pt and every icon was clipped. A docked bar now declares no safe-area inset anywhere in its chain. A floating bar is unchanged — it lifts its whole capsule instead, so the gap beneath it survives.

Featureios,android,windows,macos
ShinyTabbedPage can float its bar instead of docking it, the way iOS’s own tab bar now does. BarStyle="Floating" turns the bar into a capsule inset from the page edges — 16 either side, 8 below — with a corner radius of half the bar height, and lets the page’s content run the full height of the page underneath it rather than stopping where the bar starts. BarStyle="Docked" is still the default and is unchanged. The safe-area inset moves with the style: docked, the background keeps painting down through the home indicator so no strip of page shows beneath it; floating, the whole capsule is inset instead, because the gap under a floating bar is the thing that makes it read as floating. Setting BarMargin or BarCornerRadius explicitly still wins over both defaults. The shadow steps up a level for a floating bar, which has nothing behind it to read against. In Shell the bar is already an overlay, so ShinyTabBarBehavior needs nothing extra. Floating implies the existing ContentBehindTabBar rather than inventing a second way to say it, so the one thing to do on your side is unchanged: pad the bottom of anything scrollable by about BarHeight or its last row sits under the capsule. See TabbedPage.

Featureios,android,windows,macos
ShinyTabbedPage folds tabs into a More tab when they stop fitting, and works out for itself when that is. MaxVisibleTabs defaults to 0, meaning the cap comes from the bar’s own width divided by MinTabWidth (72), less the column the centre button owns — so six tabs are six tabs on a tablet and four plus a More on a phone in portrait, from the same markup, and the split is recomputed on rotation, on a window resize, and when a Shell bar moves to a page with different chrome. The cap counts the More tab: four over six tabs draws three real ones and a More, because adding the More cell to four would put the bar straight back over the width that caused the overflow. Set MaxVisibleTabs to pin it, MinTabWidth to say what “does not fit” means for your labels, and OverflowTitle/OverflowIcon to relabel. See TabbedPage.

ios,android,windows,macos
Two details that decide whether the More tab reads as working. It draws as selected whenever the tab on screen is one it folded away — it selects nothing of its own, so without that the whole bar reads as having nothing selected the moment you pick something out of the menu. And a folded tab keeps its badge, on its row in the menu: a tab that just disappeared behind More is exactly the one whose unread count nobody can see any more. The menu reuses the centre button’s card, backdrop and animation, anchored to the trailing edge instead of the middle; the centre button no longer rotates into its close glyph for a menu it did not open. New on ShinyTabBar: HasOverflow, OverflowItems, OpenOverflow(), SelectOverflowItem(item), and tab-more as the cell’s AutomationId.

Featureios,android
A ShinyNavigationPage’s bar colour now reaches the status bar, and its contents stay out of the notch. MAUI applies a safe-area inset by offsetting the view that carries it rather than padding it, so SafeAreaRegions.Container goes on the view inside the bar’s background — on the bar or on its Border the whole background moves down and the strip above it stays the page’s colour, which is the failure this removes. Three Grids above it are now SafeAreaEdges="None" so the background can reach the top edge at all: the bar itself, the grid a page’s content is wrapped in, and the overlay root every Shiny page grows. ShinyTabBar carried the same mistake at the bottom edge and is corrected with it. RespectSafeArea="False" opts out for a bar meant to sit under the status bar. The page’s own content is untouched and keeps MAUI’s default for its type, so a layout still insets itself out of the home indicator. See NavigationPage.

Featureios,android
StatusBarStyle picks the clock and icon colour off the bar’s background. Auto — the default — reads the bar by relative luminance rather than HSL lightness, which is the difference between a readable clock and a white one on a light bar: HSL calls pure blue and pure yellow equally light, and an indigo bar is exactly that case. LightContent, DarkContent and None pin it, ShinyNav.StatusBarStyle overrides it per page, and StatusBarColor pins the colour for a bar painted with an image or pattern brush that has no single colour to read. A gradient needs nothing: the stop at offset 0 is the end the status bar sits over.

ios,android
What each platform can actually do. Neither iOS nor Android 15 has a status bar background to set — what shows behind the clock is whatever the app paints there, which is now the bar. Below Android 15 the strip is still filled with SetStatusBarColor, so the two paths agree, and the background half is verified working on both platforms. The foreground — the clock and icon colour — works on iOS and Mac Catalyst, where it needs UIViewControllerBasedStatusBarAppearance set to false in Info.plist (UIKit’s rule, and the same one MAUI’s own BarTextColor handling already lives under). On Android it does not currently take: the style resolves and both the AndroidX and the platform writes execute, but the system re-asserts the theme’s appearance afterwards; a race, the activity lookup and the AndroidX wrapper have all been ruled out on an API 36 emulator. Choose an Android bar colour that reads against the system’s own icons for now. Windows, GTK4 and macOS AppKit have no status bar and are unaffected.

Featureios,android,windows,macos,blazor
Slider and RangeSlider thumbs can hold your own content. ThumbTemplate — plus LowerThumbTemplate / UpperThumbTemplate on RangeSlider, so the two ends can label themselves independently — draws a DataTemplate (MAUI) or RenderFragment<double> (Blazor) inside the thumb, handed that thumb’s own value. This is the case the tooltip does not cover: a tooltip is a thing hovering above the track that you turn off to get a clean control, and putting the reading, an icon or a state glyph in the grip is a different design — a volume slider whose knob is a speaker, a temperature slider whose knob reads 21°.

ios,android,windows,macos,blazor
The thumb does not grow to fit, so it gained a real box: ThumbWidth, ThumbHeight, ThumbCornerRadius and ThumbPadding. The travel is the track less one thumb and every mark, tooltip and band offset is measured from it, so the thumb’s size has to be known before anything is laid out — a thumb that sized itself from its content would make the geometry depend on a measurement that only exists after the geometry. ThumbWidth/ThumbHeight default to -1, meaning “stay square at ThumbSize”, which is what every existing slider keeps doing. ThumbCornerRadius is fully rounded by default and follows the shorter side, so a 52×28 thumb is a pill rather than a rectangle with a circle’s radius, and the along-axis extent is what eats into the travel — a wide thumb on a horizontal slider still parks flush with both ends instead of hanging off them.

ios,android,windows,macos,blazor
On MAUI the template is realized once and rebound as the thumb moves. Rebuilding it per draw would throw the content away on every pixel of a drag — losing any animation or state the template set up — and on net10.0-macos a view added after the page has laid out is never realized at all, so the instance that survives a drag has to be the one built first. Only a change of ThumbTemplate itself rebuilds. The content is also marked input-transparent: Border does not pass that down to its child, and without it the content swallows the drag before the gesture ever sees it.

Fixblazor
A Blazor slider thumb with anything inside it could not be dragged. The pointer handler tested e.target’s own class name, and a thumb with content in it hands the pointerdown to that content instead — so the drag simply never started. It now walks up with closest(), the way RangeSlider already did. Clicking a thumb no longer seeks the track underneath it either, which is only noticeable once a thumb is big enough to hit by accident.

Fixblazor
The band below a Blazor slider now reserves the half of the thumb that hangs past the track. It previously reserved that space only when there were mark captions to put there, so the thumb overhung its own box and could sit on whatever was laid out next; the same allowance is now made at both ends of RangeSlider. Sliders in a tight stack will be a few pixels taller. The thumb also sizes with box-sizing: border-box, matching MAUI, so ThumbSize is the whole thumb rather than the thumb plus its ring.

Fixblazor
RangeSlider wrote culture-dependent numbers into its inline styles. Under a locale with a comma decimal separator every declaration carrying a fraction was silently invalid and dropped by the browser, leaving the thumbs and the fill in the wrong place; every number now formats invariantly, as Slider already did. A splatted style from the caller no longer replaces the component’s own, either.

Featureios,android,windows,macos,blazor
DiagramView — hierarchies, decision trees, flowcharts and mindmaps on both hosts, off one layout engine. A bindable graph of shapes and connections with five auto-layouts, connectors that anchor to the real outline of each shape, and a surface you can pan, zoom, marquee-select, drag nodes on and author connections in. Eleven flowchart shapes — the vocabulary rather than a general shape library, so there is a diamond and a parallelogram and no star. Ships as its own add-on package per host (Shiny.Maui.Controls.Diagram / Shiny.Blazor.Controls.Diagram). This is not the Mermaid control: that renders a picture from mermaid text, and this is a graph you bind to and edit — reach for Mermaid when the source of truth is markup and for this when it is a collection of nodes. See Diagram.

ios,android,windows,macos,blazor
The whole engine is a separate, shared package — Shiny.Controls.Diagram.Shared — and neither control computes geometry. A diagram is mostly arithmetic: packing a tidy tree so a parent sits centred over its children and no two subtrees collide, assigning layers to a graph that loops back on itself, working out where a line leaving a diamond actually crosses its edge. None of it wants to know whether it is drawing into a MAUI canvas or an SVG viewBox, so it doesn’t — the package owns the model, all five layouts, the routing and the shape geometry, down to the exact box every node occupies and the exact polyline every connection follows. A layout that resolved differently on the two hosts would be a bug neither host’s own tests would notice, and this removes the possibility rather than testing for it. Dependency-free, trimmable, AOT-clean, and perfectly usable with no UI at all.

Featureios,android,windows,macos,blazor
Five layouts, and every one of them is deterministic. Tree is Buchheim, Jünger and Leipert’s linear-time tidy tree extended for nodes of different sizes — a parent centred over its children, no subtree overlaps, and two identical subtrees drawn identically wherever they appear. Layered is the Sugiyama pipeline for a flowchart that rejoins or loops back, threading a long edge around the nodes it spans rather than through them. MindMap splits the root’s children across two sides, Radial puts each level on a ring with wedges weighted by leaf count, and ForceDirected relaxes a network. The force-directed layout seeds from a fixed circle, not at random, because a random seed would mean the two hosts settle the same graph into two different pictures — and the same control into a different picture on every rebuild. Ordering passes break ties by original index rather than relying on an unstable sort, for the same reason. See Layouts.

ios,android,windows,macos,blazor
Connectors anchor to the shape, not to its bounding box — which is most of what makes a decision tree look drawn. With a box anchor, every arrow into a diamond stops short in mid-air at the corner of an invisible rectangle, and a click in that same empty corner selects the node. Every shape is reduced to a real outline that both the ray intersection and the hit test run against; circles and ellipses are done analytically instead, because a tessellated circle anchors arrows onto flat spots that are visible at the zoom levels this control supports. Auto ports then snap to the layout’s own axis under a hierarchy layout, so a top-down chart’s arrows arrive in the tops of its boxes rather than in their sides; under Radial and ForceDirected there is no axis to prefer and the nearest edge wins.

Featureios,android,windows,macos,blazor
A hierarchy with no connections still draws its links. An org chart handed over as nested nodes has parents and children and no edges at all, and without this it would draw as a tidy grid of boxes joined by nothing — which looks like the control failed rather than like a deliberate absence. Declaring a connection between the same two nodes replaces the implicit one, so adding a label or a different arrowhead is just saying so. Hierarchy itself can be handed over either way round and both at once — a flat list where each node carries a ParentId, the shape rows arrive from a database in, or nested Children, the shape a view model is usually already in.

Featureios,android,windows,macos,blazor
Every edit produces a plan before anything moves, which is where cancellation and undo both come from. A gesture builds a DiagramEditPlan listing everything it is about to do — deleting one node also lists the connections that go with it — computed, raised, and not yet applied. Editing (MAUI) / OnEditing (Blazor) hands you that whole consequence and takes a Cancel, so a handler vetoes with the full picture in front of it rather than after the fact. And because the plan recorded the old values rather than trying to recompute them, Revert() puts everything back including the cascade — undo is a Stack<DiagramEditPlan> and nothing else. A plan also holds the collections it edited, so reverting a delete puts the node back in its old slot rather than on the end: supply order is what the layouts break ties by, and an undo that reordered the source would move unrelated nodes. See Editing & Undo.

ios,android,windows,macos,blazor
Editing is off by default, and dragging a node pins it. AllowNodeDrag, AllowConnectionEdit, AllowMultiSelect and AllowDelete all start false; a diagram is a picture until you say otherwise. AllowDelete is Blazor-only — MAUI has no reliable cross-platform key event on a content view and a phone has no Delete key, so there it is a DeleteSelection() method a button calls. Dragging sets IsPinned, which holds the node through the next re-layout: an auto-layout that snapped a hand-placed node back the moment anything changed would make dragging pointless. The space it occupied is still reserved, so its neighbours do not close over the gap.

ios,android,windows,macos,blazor
The property is LayoutKind, not Layout — on both hosts. On MAUI a property named Layout hides VisualElement.Layout(Rect), which is the class of collision that leaves a control looking unwired rather than broken. Nothing forces the rename on Blazor, but two hosts with the same feature under two names is a worse trap for anyone writing against both.

ios,android,windows,macos,blazor
Validation reports, it does not throw. Real diagram data routinely contains a dangling connection, a duplicate id, or a parent chain that loops; a control that threw on one would be unusable against it, and one that silently dropped it would be worse. The diagram still draws and DiagramModel.Issues says what is wrong — read it from Built / OnBuilt, because nothing else will tell you. Cycles in the parent chain are cut and reported rather than left to hang a layout pass, and every graph walk in the engine is bounded for the same reason.

ios,android,windows,macos,blazor
Nodes are drawn, not composed — until you ask for views. A two hundred node diagram is two hundred native views on MAUI, each with a handler, a measure and a layout pass, which is the difference between a diagram that pans at sixty frames and one that stutters; so by default every node is one drawn path on MAUI and one <path> on Blazor. Setting NodeTemplate opts into real views for the cases that need a button or an image inside a node. On Blazor, pan and zoom are the SVG viewBox rather than a transform on the contents, so zooming costs nothing at any node count and stroke widths stay honest.

Featureios,android,windows,macos,blazor
DiagramJson round-trips a diagram, and deliberately saves less than it could. The graph, the shapes and hand-placed positions go through a flat pair of lists — nesting a node inside its parent would make a file that cannot express a node with a parent it also has a connection to, and would turn every diff into a reindented mess. Routes, depths and selection are not saved: they are derived on the next rebuild, so saving them would only create the possibility of a file disagreeing with itself. Serialization is source-generated, so it survives a trimmed WebAssembly publish rather than failing only there.

Featureios,android,windows,blazor
FloorPlanView — a floor plan you can draw on, and the same plan as a seating chart. Rooms, walls, doors, cubicles, outlets, furniture and custom SVG stencils on a pan-and-zoom SkiaSharp surface, in two new add-on packages (Shiny.Maui.Controls.FloorPlan, Shiny.Blazor.Controls.FloorPlan) over one shared engine. Mode picks between two controls that happen to share a name: Edit hands the pointer to the active tool and puts eight resize handles on the selection, while View turns the whole surface into a pan surface, blocks every edit, and raises ElementTapped for a press that never turned into a drag. That second one is not a degraded editor, it is the seating chart — the case with no toolbar, where the entire interaction is “tap a desk and see who sits there”. See Floor Plan.

ios,android,windows,blazor
The engine is its own package, and that is also why the control is not in the core ones. Shiny.Controls.FloorPlan.Shared owns the document model, the renderers, the hit-tester, the camera and every tool; both controls are a surface and an event pump around one FloorPlanEngine, so a plan cannot behave differently on the two hosts. The packaging is the load-bearing part: the engine is SkiaSharp all the way down, and SkiaSharp in Shiny.Maui.Controls / Shiny.Blazor.Controls would hand the native binaries to every consumer of those — including every Blazor WebAssembly consumer, who would then need the WebAssembly native build toolchain to publish anything at all, whether or not they had ever drawn a floor plan. On Blazor the package fails the build (SHINY0001) when that toolchain is absent rather than shipping an app that throws DllNotFoundException: libSkiaSharp on its first frame.

ios,android,windows,blazor
Element colours are nullable and default to the theme, which is what makes one saved plan readable in both schemes. ElementStyle.FillColor and StrokeColor are string? and default to null, meaning “whatever the current FloorPlanTheme says” — a document that hard-codes #CCCCCC for every room can only ever be viewed on white, and no amount of app-level theming rescues it. Set a colour and it is honoured exactly in both schemes, which is what an authored plan that colour-codes its meeting rooms actually wants. The theme itself follows the app with no configuration: MAUI reads Application.Current.Resources, Blazor reads the --shiny-color-* custom properties off the element, and both end at FloorPlanSurface so the two hosts cannot drift. Only the neutrals follow — the selection blue means this is what you have hold of and the furniture colours mean this is wood, that is upholstery, and restating either in an app’s accent is not theming, it is a different control. Same rule the Office surfaces follow.

ios,android,windows,blazor
Hit-testing is against each renderer’s own silhouette, at the camera’s real zoom. A diagonal wall’s bounding box is mostly empty room, so testing against the box means a click several plan-metres off the wall selects it — the kind of wrongness nobody can point at but everybody feels. Every renderer hands back the shape it actually drew: a wall is the quad it was stroked into, a chair is a circle, an outlet is a disc with a floor under it so a symbol on a zoomed-out plan is still bigger than the pointer. Resize handles are sized in screen pixels and their grab area is divided back out by the zoom, so they stay the same size to grab at any magnification — and they are only live in Edit mode, because handles that are not drawn should not be swallowing taps.

ios,android,windows,blazor
A drag snaps on release, and a press in View mode is not yet either thing. Snapping every pointer move makes an element jump ahead of the finger, and a nudge smaller than one grid cell does nothing at all, so a drag tracks freely and lands on the grid when it ends. In View mode a press is held undecided until it has travelled more than five screen pixels: past that it is a pan, short of it a tap. Without the threshold a seating chart is unusable on touch, because no finger holds perfectly still, and every attempt to identify a desk would come out as a nudge of the camera instead.

Featureios,android,windows,blazor
Tools are an open interface, and the placement ghost is the real element. SelectTool, PanTool, DrawRoomTool, DrawWallTool and PlaceElementTool ship in the box; IFloorPlanTool is public, and a tool’s preview gets a DrawElement callback that paints through the registered renderer, so the ghost under the pointer is the actual desk or door about to be dropped rather than a generic marker you cannot tell apart from a bookshelf until after you have placed it. DrawWallTool is click-click rather than click-drag and chains by default, because a run of walls is a long series of points and holding a drag for each corner is exhausting on a mouse and impossible on a phone. ActiveTool is bindable on MAUI and a parameter on Blazor, so a toolbar is a set of commands rather than a pile of code-behind — build a new tool instance each time, since a tool holds the state of the gesture in progress.

ios,android,windows,blazor
Fitting happens on the first painted frame, not on the first layout. The natural place to call ZoomToFit() is a page constructor or an OnAfterRenderAsync, and at neither moment does the drawing surface have a size — fitting to a collapsed box leaves the plan a thumbnail in the corner, which is exactly what the Blazor sample did while its splash screen was still up. The canvas’s own backing store is the one honest answer, so MAUI holds an early ZoomToFit() and applies it on the next frame, and Blazor’s FitOnLoad (on by default) fits on the first frame with a real size. ZoomToFit, ZoomIn, ZoomOut and ScrollTo stay methods rather than bindable properties for the same reason: they all need the surface size, and a view model has no business knowing it.

macos
The macOS AppKit head is not supported yet. SkiaSharp.Views.Maui ships no net10.0-macos asset, so that head resolves a handler whose CreatePlatformView throws and the page comes up blank with nothing useful in the log. Shiny.Maui.Controls.Office already carries a replacement canvas handler that fixes it app-wide, so an app referencing both gets a working plan on AppKit; until that handler moves somewhere both packages can share, Mac Catalyst is the supported macOS story.

Featureios,android,windows,macos,blazor
GanttView — a project timeline on both hosts, off one scheduling engine. A task grid on the left, a scrolling time axis on the right, and bars you can drag: hierarchy with rolled-up summary bars, milestones, baselines, deadlines, constraints, all four dependency types with lag, working-time calendars, and a critical path. The hierarchy can be handed over either way round and both at once — a flat list where each task carries a ParentId, the shape rows arrive from a database in, or nested Children, the shape a view model is usually already in — because forcing a conversion on the consumer is how a control ends up with two half-supported paths anyway. Summary dates and progress roll up from the children, progress weighted by duration, so a two-hour task at 100% cannot drag a six-week task at 0% up to “half done”. See Gantt.

ios,android,windows,macos,blazor
The whole engine is a separate, shared package — Shiny.Controls.Gantt.Shared — and neither control does date arithmetic. A Gantt is mostly arithmetic: that a three-working-day task dragged onto a Friday now finishes on Wednesday, that pushing it drags four successors with it, that one of those has a must-start-on constraint holding it, and that the whole chain is now critical. None of that wants to know whether it is drawing into a MAUI layout or a CSS grid, so it doesn’t — the package owns the model, the calendars, the scheduler, the critical path, and the time-to-pixel geometry down to the exact rectangle every bar occupies and the polyline every arrow follows. A plan that schedules differently on the two hosts is a bug neither host’s own tests would ever notice, and this removes the possibility rather than testing for it. The Scheduler’s agenda geometry is duplicated across the hosts with a comment asking future maintainers to keep the copies in sync; that is a fair bet for eighty lines of minutes-to-pixels and a bad one for a scheduling engine. The package is dependency-free, trimmable and AOT-clean, and is perfectly usable with no UI at all.

Featureios,android,windows,macos,blazor
Every drag produces a plan before anything moves, which is where cancellation and undo both come from. A gesture builds a GanttSchedulePlan listing the edited task first and every task the auto-scheduler would drag along after it — computed, validated, and not yet applied. TaskChanging (MAUI) / OnTaskChanging (Blazor) hands you that whole consequence and takes a Cancel, so a handler vetoes an edit with the full picture in front of it rather than after the fact. And because the plan recorded the old values rather than trying to recompute them, Revert() puts every task back including the cascade — undo is a Stack<GanttSchedulePlan> and nothing else. CascadeMode defaults to PushOnly: successors are pushed later when a link would otherwise break, and never pulled earlier, because pulling a task forward because its predecessor finished early is not something a planner asked for. Strict re-derives in both directions; None moves nothing and reports the broken links instead.

Featureios,android,windows,macos,blazor
Working-time calendars, and they are free when you don’t use one. GanttCalendar carries working days, shifts within a day, holidays and per-date exceptions — an empty exception turns a weekday off, a populated one turns a Saturday on, which covers both a company shutdown and weekend overtime with one mechanism. With one set, weekends shade, a bar dropped on a Saturday lands on the Monday, a task keeps its working duration across a move so three working days stays three working days and simply gets wider, and lag and slack are measured in working time too. GanttCalendar.Continuous is the default and short-circuits every method to plain arithmetic, so an app that never mentions working time never pays for the machinery. Every walk over days is bounded: a calendar with no working days at all clamps and returns rather than freezing inside a layout pass.

ios,android,windows,macos,blazor
The critical-path pass runs backward only, and slack is allowed to go negative. A textbook CPM starts by deriving earliest dates from durations, but a Gantt control is handed a plan that is already scheduled — those dates are the answer, not something to recompute. What is unknown is how much each task can slip before the finish moves, and that comes from walking the graph in reverse. So slack can come out negative when the plan already violates its own dependencies, which is the honest answer and the number a planner actually needs. A dependency is drawn as critical only when both ends are, because a red arrow across slack that genuinely exists is worse than no highlight at all. Tasks caught on a dependency cycle are reported and left out rather than the analysis hanging.

Fixios,android,windows,macos
Panning the chart on MAUI, which the gesture recogniser was quietly eating. A PanGestureRecognizer on a child of a ScrollView consumes the drag — and the Gantt needs one, because that is how a bar is dragged. The result was the worst kind of half-working: a fast flick still scrolled, because UIScrollView wins a fling outright, while an ordinary slow finger pan did nothing at all. That reads as flaky rather than broken, which is why it survived a compile-clean build and a full headless suite. The control now drives the pan itself when the press did not land on a bar, locking the native scroller out for the duration so the two cannot both move the content — identical on every platform instead of depending on which recogniser each one favours. The cost is momentum, which a hand-driven pan cannot have; AllowPan turns it off. On Blazor the browser keeps the gesture and only the bars set touch-action: none, so panning there is native and does have momentum — previously the canvas claimed the horizontal axis outright, which took sideways panning away from the whole surface on touch.

Fixios,android,windows,macos
The timeline header painted over the task pane once scrolled. The header is a wide GraphicsView translated to follow the scroller, held inside a viewport — and IsClippedToBounds did not hold it. Scrolled a few days in, September’s day numbers were drawn straight across the task pane’s own “Task” column. It now carries an explicit Clip geometry, resized with the viewport.

Fixios,android,windows,macos
The upper header tier went blank while panned into the middle of a month. The month label was anchored to its cell’s left edge, and a month is normally far wider than the screen — so for most of the time spent inside one, the row that exists specifically to tell you which month you are looking at showed nothing. The label now pins to the viewport edge while its cell is still in view.

Fixios,android,windows,macos
Bars sat a row and a half below the rows they belonged to when the plan was short. A ScrollView centres content smaller than its viewport, so a twelve-row plan in a taller viewport had its whole timeline canvas placed at half the leftover height while the task pane started at the top. Both axes of the content are now pinned to the start — the vertical case is the one that showed, but ZoomToFit narrowing the content reproduced it horizontally. Invisible in headless tests, since nothing arranges without a handler, and invisible on Blazor, where absolute positioning in a scroll container is top-left by nature.

ios,android,windows,macos,blazor
Validation reports, it does not throw. Real project data routinely contains a dangling dependency, a duplicate id, or a task that misses its deadline; a control that threw on one would be unusable against it, and one that silently dropped it would be worse. A plan that fails validation still renders and GanttModel.Issues says what is wrong — read it from PlanBuilt / OnPlanBuilt, because nothing else will tell you. Cycles are the single case that genuinely blocks: a cyclic graph has no schedule to compute, so drawing a link that would close one is refused outright.

Featureios,android,windows,macos
CameraView.ShowPreview — the picture off, the camera on. A dash cam, a monitoring rig or any long unattended capture wants the camera running for hours and the full-screen moving image gone: it is a distraction to whoever is in front of it and a share of the battery spent on something nobody is looking at. Until now the only switch available was IsActive, which owns the session — turning it off stops the camera and ends any recording with it. ShowPreview = false stops only the drawing. Frames keep reaching the analyzer, the file keeps being written, and a burned-in overlay keeps being burned in, because the overlay rides the recording path and has never touched the preview one. It is safe to toggle mid-recording on every platform, which is the whole point of it existing as a property rather than as advice about hiding a view. See Recording without the preview.

android
On Android it hides the view and makes no CameraX call at all — deliberately, and not because that was easier. Dropping the Preview use case would save more: the camera stops producing preview frames, rather than producing them for a view that is not composited. But every use-case change in the handler goes through cameraProvider.UnbindAll(), and that drops a recording in progress. A property that stops a dash cam filming because somebody hid the picture has failed at the only thing that mattered, silently, and it is discovered when the footage is needed. So the use case stays bound and the PreviewView goes Invisible — SurfaceFlinger stops compositing it and the TextureView stops uploading a texture per frame. Invisible rather than Gone for a second reason: Gone takes the view out of layout, which resizes the PreviewView to nothing and hands CameraX a surface change nobody asked for. Apple and macOS have no such constraint — the preview layer and the writer are separate — so there both the layer and its connection are switched off.

Featureios,android,windows,macos
IMediaService — a camera you call, not a screen you build. 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 — and MAUI’s own IMediaPicker gets them there by handing the job to the system camera UI, which cannot show a scan reticle, a bounding box, an effect strip or a word of your own copy. So every app that needs any of that hand-rolls a camera page. That page is now the service: inject IMediaService (registered by UseShinyCamera(), in Shiny.Maui.Controls.Camera) and get camera and gallery permissions, TakePhotoAsync / RecordVideoAsync through a modal built from CameraView, PickPhotoAsync / PickPhotosAsync / PickVideoAsync, and — from the analyzer add-ons — ScanBarcodeAsync, ScanTextAsync, ScanCreditCardAsync, ScanDriversLicenseAsync, ScanPassportAsync, ScanHealthCardAsync, ScanReceiptAsync, ScanInvoiceAsync, ScanBusinessCardAsync and DetectFacesAsync. Everything that presents UI asks for its own permissions first and returns null on a cancel or a refusal rather than throwing, because a cancelled camera is an ordinary outcome. MAUI only: the modal is a MAUI page, and on Blazor the browser’s file input already covers picking. See IMediaService.

Featureios,android,windows,macos
Every scan comes in two shapes: one result, or a stream. ScanBarcodeAsync returns Task<DetectedBarcode?> and closes the modal on the first hit; ScanBarcodesAsync returns IAsyncEnumerable<DetectedBarcode> and keeps it up, streaming as codes are read. The plural form 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”, and cancellation, the user tapping ✓, MaxResults and Timeout all end it the same way. filterDuplicates (default true) suppresses a result already returned in the session, because a code sitting in front of the lens is otherwise re-read every time it drifts out of view and back. The key is chosen per type rather than being 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; the card number for a credit card; merchant, date and total for a receipt, which carries no identifier reliably at all. The explicit argument wins over the options property, so the shorter spelling is never the one that silently does nothing.

ios,android,windows,macos
The modal ships no localizable strings of its own. Close, torch, flip, flash, retake and accept are drawn vector paths rather than font glyphs or emoji — a glyph needs a font the package cannot assume, and an emoji renders at a different size, weight and colour on every head, so the same modal would look like three different products across iOS, Android and Windows. The practical half matters more than the aesthetic one: there is nothing on the page to translate, and the only text it shows is the Title and Instructions the app supplies already localized. There is deliberately no CancelText/DoneText/ConfirmText because there is nothing for them to name. A scan modal also has no capture button at all — not a hidden one — since the camera is simply on and streaming results and a shutter would invite a tap with nothing for a still to be the result of; close is hidden for the length of a recording for the same class of reason, because dismissing mid-capture strands a half-written file.

ios,android,windows,macos
Compression is a property, and its defaults live at registration. CompressionQuality, MaxDimension and OutputFormat are applied before the photo reaches the caller, and UseShinyCamera(media => …) sets what every call inherits — so “our photos are 85% JPEG capped at 2048px” is stated once rather than at twenty call sites. They are nullable on the per-call options for exactly that reason: without an unset state there is no way to tell “the caller wants 92” from “the caller said nothing”, and an app-wide default would silently never apply. Nothing is re-encoded when nothing was asked for, so a full-size JPEG at quality 100 is handed straight through rather than decoded and recompressed for no reason. MaxDimension is the setting that actually shrinks a file — a 12MP capture stays multi-megabyte at any compression rate.

ios,android,windows,macos
Zoom is applied against the range the lens actually reports. CameraView.MaxZoom is 1 until the handler has opened and interrogated the camera, and Zoom coerces against it — so assigning a starting zoom when the page is built clamps it to 1 and loses it with nothing to show that it happened. The modal holds the request and applies it when the range arrives, which also gives the two new knobs somewhere to stand: MaxZoom caps how far the user can get (a ceiling that never raises a weaker lens and never falls below its minimum, since the far end of a phone’s range is digital crop — the picture gets bigger and no better), and AllowZoom = false pins the usable range shut rather than only disabling the pinch gesture, because otherwise a ConfigureCamera hook or a binding could still zoom past 1× and the option would be a UI hint rather than a rule. Both are re-applied when a lens switch republishes the range.

ios,android,windows,macos
MediaPermissionStatus has no PermanentlyDenied, on purpose. Whether a second request will re-prompt is not knowable the same way on both platforms — iOS silently returns the previous refusal while Android answers through ShouldShowRationale — so a value claiming to mean it would be right on one head and a guess on the other. The four that survive are Granted, Denied, Restricted and Unsupported; iOS’s limited photo selection counts as granted, because the user chose what you may see and you may proceed. Treat Denied as “offer them OpenSettingsAsync”, which is correct either way. Asking for the microphone alongside the camera returns the weakest of the two, since “granted” has to mean every permission the call needs or a caller that checks it still ends up with a silent movie.

ios,android,windows,macos
The service knows nothing about barcodes. It exposes one primitive — ScanAsync<T>(MediaScanRequest<T>) — and each analyzer package contributes its own typed verb on top, which is why installing .Camera.Documents is what makes ScanCreditCardAsync exist. The request carries the analyzer, a Subscribe callback that wires that analyzer’s own strongly-typed OnDetected to an emit delegate, and optional duplicate-key and describe selectors. Subscribe is there because an analyzer delivers through a typed member the service cannot see without knowing its type, so the code that does know it does the wiring and everything else — permissions, presentation, arming, duplicate filtering, cancellation, teardown — is written once instead of once per result type. It is public, so an analyzer Shiny ships no verb for is a dozen lines rather than a fork.

Featureios,android,windows,macos,linux,blazor
SlideView presents. A viewer shows a deck to the person holding the device; presenting mode shows it to a room. IsPresenting (or StartPresenting() / StartPresentingAsync()) 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 or clicking 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: the surround is black whatever the app is set to and the slide’s border is dropped, since 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. See Presenting mode.

ios,android,windows,macos,linux
On MAUI the show is a modal page, not a re-parented viewer. Moving a view in the tree rebuilds its platform view — a visible stall on a canvas — and lifting the viewer out of whatever layout the consumer put it in means putting it back exactly right afterwards, so the show page carries its own SlideView over the same deck. Its own controller too, not the owner’s: 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, because its own size never changed. Only the index crosses back, which is the piece a host actually binds to. A modal page is also what makes the platform back gesture work — dismissing it ends the show through IsPresenting rather than popping out from under the viewer — and the display is kept awake for the duration (KeepScreenOnWhilePresenting).

blazor
On Blazor the CSS covers the viewport and the Fullscreen API is asked for on top of it, in that order. requestFullscreen can be refused — an iframe without allowfullscreen, iOS Safari, prerendering, a gesture the browser did not like — and a refusal still has to give the room a full-window deck rather than nothing at all, so the class does the covering and fullscreen only removes the browser chrome. The component also drops its own inline Style while presenting, or a height from the host would beat every rule in the sheet. A fullscreenchange listener keeps IsPresenting honest about exits nobody asked it for, since Escape and F11 never reach the component’s key handler; 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.

Fixios,android,windows,macos,linux
The presenter bar declares its own visual states. On the iOS pass the arrow at the end of a deck came up wearing a grey pill: the sample app’s implicit Button style was reaching into the show’s chrome, and its Disabled visual state paints a background that beats the local BackgroundColor the bar sets. Giving the buttons an explicit (empty) Style stops the app’s style being applied but not that — a visual state setter outranks a local value whatever style is in scope — so the bar now carries its own CommonStates group, transparent in every state, which also puts the disabled look where it belongs: a spent arrow dims to 35% rather than growing a box. A presenter bar has to look the same in every app that hosts it; anything an app can restyle from a distance is a bar that is only correct by luck.

Featureios,android,windows,macos,linux,blazor
Speaker notes, in the show. The bar’s Notes button appears only when some slide in the deck actually has notes, and the panel it opens 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. The sample decks on both hosts now carry real speaker notes, which also means the viewers’ existing notes strip finally has something to show.

Featureios,android,windows,macos,linux,blazor
A TimelineView on both hosts. 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, for an activity feed, an order’s progress, an audit trail, a changelog. 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 as 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 whatever the index says, for a history where everything has already happened and a trailing pending tail would be saying something untrue. That one wins outright rather than being merged with the index, so switching it on does not need the index moved to the end, nor moved back again to switch it off. See Timeline.

ios,android,windows,macos,linux,blazor
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 item template produced, and the connector stretches to fill it — so a node with a paragraph beside it simply makes its own segment longer. 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, and content beside a timeline is arbitrary and self-sizing by definition. 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, which would drift further out of alignment the taller the content got. The gap between nodes belongs to the row’s own padding rather than to spacing between rows, because spacing is dead space nothing paints in — and the rail would come apart into disconnected segments. Not virtualized on either host: the deliberate trade for rows of differing height, which is exactly what a recycling list is worst at.

ios,android,windows,macos,linux,blazor
The three templates deliberately bind to different things. ItemTemplate and OppositeTemplate take the item, because content beside a timeline is ordinary content and should not have to 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 the node’s position and none of it exists on the item. Left unset the marker is a themed dot, which is what most timelines want. On MAUI that dot is a BoxView rather than a Border for a specific reason: a BoxView’s fill is a Color and a Border’s is a Brush, and a colour theme token assigned to a brush property is silently dropped — which shows up as a control that simply never follows the theme pack.

Fixios,android,windows,macos,linux
RailPosition="Right" staggered the rail and clipped the content on MAUI. Each node is a three-column grid, and the columns were pinned as Auto / rail / Star whichever side the rail was on — so with the rail on the right the content landed in the Auto column and the short opposite label took the Star. Every row then sized itself to its own text: the markers stepped in and out instead of forming a line, and the long entry was clipped at the window edge. The content side is the Star column and the opposite side the Auto one now, whichever side each is on. Blazor was always laid out correctly; this was MAUI only, and it is what the first device pass of the control turned up.

Fixblazor
The wizard’s dot markers had no hover state. The chevron indicator has had one all along, so on a wizard where steps can be jumped to, the chevrons responded to the pointer and the dots sat there inert — the same affordance, present in one style and missing in the other. They now take a state ring on hover, and on :focus-visible too, so a keyboard gets the same feedback a mouse does; a step that can be tabbed to and gives nothing back looks broken. A ring mixed from the accent rather than the brightness() filter the chevrons use, because a filter darkens whatever is already there — which reads as a press on a light scheme and as almost nothing on a dark one. A marker not yet reached also lifts towards the accent, so hovering says “you can go here”. All of it is gated on the step actually being selectable, so a step you cannot jump to never advertises that you can. See Wizard.

Fixblazor
The wizard sample’s indicator demo could not be clicked. It never set AllowStepSelection, so every marker in it was disabled — which is why the dots appeared to have no hover behaviour even once they had one. The demo is selectable now, so the indicator’s own click and hover behaviour is reachable where it is being shown off.

Fixblazor
Closing an ImageEditor at the wrong moment took the whole page with it. The component holds a JS module, and a call could always land after teardown had begun — a queued render syncing parameters, a pointer gesture the script already had in flight, an operation still awaiting when the host switched views. Checking the field for null never covered that: the reference can be disposed while a call is on the wire, and IJSObjectReference throws ObjectDisposedException rather than returning. Unhandled async work from a component is not caught by whatever closed the view — it reaches the renderer and tears down the circuit — so one editor being removed mid-operation blanked every other component on the page, which is the worst possible blast radius for a control going away. Interop now runs through a single guarded path that stops once disposal starts, DisposeAsync raises that flag before releasing anything and is idempotent, and the [JSInvokable] callbacks return early so a late gesture cannot raise CanUndoChanged at a host that has already dropped the component. An editor removed while its module import is still in flight releases the module and abandons initialisation rather than wiring handlers to something that is already gone. Only disconnection, disposal and cancellation are swallowed — a genuine JSException still surfaces, because hiding one turns a bug into an editor that silently stops responding. See ImageEditor.

Fixblazor
The Notebook ribbon’s Insert tab crashed on click. It handed the shared insert menu a Rejected callback where the component declares ImageRejected. Blazor resolves parameter names by reflection when the markup renders, so the typo compiled cleanly and then threw InvalidOperationException: does not have a property matching the name 'Rejected' — and because the markup sits behind a ribbon tab, nothing reached it until somebody clicked that tab. The menu also always offered a table, which a notebook page cannot hold; it takes a ShowTable switch now, alongside the ShowShapes one that already existed for exactly this reason, and the Notebook turns both off — shapes come from its own Shapes tab, and a picker whose result the host cannot place reads as broken rather than absent.

Choreblazor
A scan now checks every component attribute against the parameters that component actually declares. The Insert crash was the whole failure mode in one line: a name the compiler never checks, on markup a build never renders. ComponentParameterCoverageTests walks every .razor in every shipped Blazor package, resolves each component’s parameters through its base types and @inherits, and fails on an attribute that is not one of them — including both halves of a @bind-, and skipping anything that splats unmatched attributes. It scans source rather than reflecting over types, because the add-ons are where a component is most likely to be consumed by someone who did not write it, and that is exactly where this one went wrong.

Fixios,android,windows,macos,linux
NotebookEditorView threw before it could draw anything on MAUI. Its constructor called ApplyAccent() straight after building the ribbon, and ApplyAccent ends in RefreshNavigation(), which reads the section tabs and the page list — fields the constructor had not created yet. So every construction threw a NullReferenceException and the control could not be placed on a page at all. Through Shell that surfaced as a native SIGSEGV inside UIKit during GoToAsync, with no managed frames in the crash report and nothing naming the notebook, which looks nothing like an uninitialised field in a constructor — the managed stack only appeared once the page was constructed outside Shell. The accent is applied last now, after the whole tree exists. Blazor builds its tree in a different order and was never affected.

Fixios,android,windows,macos,linux,blazor
A drag has to be a function of where the pointer is, not of how many times it was sampled. A drag on the notebook canvas executes a command per pointer sample so the whole gesture coalesces into one undo step — but each sample was computed from the state the previous one left behind, which compounds. On a shape that is invisible rounding drift, because a rectangle can be rebuilt from its stored bounds. On ink, whose geometry is its points, scaling the already-scaled stroke multiplies up sample by sample: a short drag overshot the handle by thirty pixels and a real one, which is dozens of samples, threw the stroke clean off the page. The drag now captures the selected items whole at pointer-down and rebuilds every sample from those, so the result is identical at one sample or forty — which is what the regression test asserts, at both. See Notebook.

Fixblazor
A drag that wandered off the notebook canvas froze mid-gesture. Without pointer capture the browser re-targets pointermove at whatever is under the cursor, so crossing onto the page list, the ribbon or the window chrome stopped the moves and swallowed the pointerup — the item stuck where it was and the editor went on believing a drag was in progress. From the outside that reads as “dragging does not work”, which is the worst symptom something half-working can have. The canvas takes pointer capture on press and releases it on up; Blazor has no binding for setPointerCapture, so the listeners are wired in JS alongside the ones it does bind, passively enough that its own @onpointerdown still sees everything.

Fixios,android,windows,macos,linux,blazor
The page rule was drawn at divider weight. Ruled paper works because the lines sit under the words rather than competing with them, and the painter’s own default is picked at that weight — but the app-theme mapping took outline-variant raw, which is sized for something meant to be seen. On the gallery’s palette that came out at roughly twice the intended contrast against the paper, so a lined page read as a table. It is blended back towards the paper now, which keeps the app’s hue and restores the weight the default was chosen at. Every other neutral is still taken as-is; the rule is the one token here that is not chrome.

Featureios,android,windows,macos,linux,blazor
A free-form notebook, in the Office packages. NotebookEditor is the lone canvas and 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. 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, whereas 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 the 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, and the same transactional undo stack. See Notebook.

ios,android,windows,macos,linux,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; there is no “selected shape” int the way the slide editor has one, since no gesture here only ever selects one thing. Text editing is a caret inside exactly one item, entered by double-clicking it. Escape steps back out one layer at a time — it leaves the text, then puts the tool down, then clears the selection — which is the only affordance that makes a modal canvas safe to hand someone. Text formatting reaches a selected container as well as a caret inside one, unlike the slide editor: a shape with a label should go bold from one click rather than needing a double-click and a select-all first.

ios,android,windows,macos,linux,blazor
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 a stroke drawn with a pressure-capable pen and one drawn with a mouse are the same stroke at different fidelities and switching device does not change how thick the pen looks. The highlighter paints beneath every other item on the page: ink over text is ink over the words rather than under them, and even at 40% alpha that greys the glyphs, 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 — a stroke is a single path, so a gap would be painted as a straight line across what was just rubbed out. 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, and circling one word of a handwritten line would take the whole line.

ios,android,windows,macos,linux,blazor
A finger on empty canvas pans; a mouse marquees. There is no wheel to scroll with on a touch screen, so if the drag also meant “select a region” there would be no gesture left to reach the rest of the page — the same reasoning the spreadsheet and document surfaces already carry on PointerKind. Undo covers everything including the commands a drag produces per pointer sample: those coalesce, so a whole drag is one step and a whole typing run is one, and the run is broken on pointer-up so the next drag starts a step of its own instead of being folded into the last. Undoing an insert also prunes the selection, so no frame is left drawn around an item that is no longer there.

Featureios,android,windows,macos,linux,blazor
.shinynote — a zip holding JSON and the pictures. notebook.json carries the notebook, its sections and each page’s settings; pages/{pageId}.json carries that page’s items in z-order; pictures are files under media/. Pages are separate entries because a notebook is the one Office-shaped thing here that genuinely grows without bound, and a manifest that has to be parsed in full to open the page someone clicked is the wrong shape for that — it also makes a page recoverable when a neighbour is corrupt. Pictures stay as files rather than base64 in the page: base64 costs a third again in size and defeats the zip’s own deflate on already-compressed formats. This is the one editor here whose model is the truth rather than a projection of an OOXML package, so there is no reproject step and no byte-identical promise — the equivalent guarantee is that everything survives a save and reopen. Reads are lenient by design, since the files are meant to be hand-editable: a file from a newer writer opens rather than being refused, and a colour that will not parse costs a highlight rather than the notebook.

ios,android,windows,macos,linux,blazor
A page that follows the theme changes what “default text colour” has to mean. The document and the deck keep white paper in dark mode, so black text is safe; a notebook page does not, so text left at the model’s default black would be black on black. It is substituted at paint time for an ink chosen against whatever is directly behind the glyphs — a shape’s own opaque fill where it has one, the page otherwise. That second half is not a refinement: following the page alone puts pale ink on the pale fill of a callout, which is the same defect one layer down. A colour the author actually chose is honoured however it reads, because second-guessing one is how a deliberately subtle caption gets repainted. The same reasoning applies to the pen: an unchosen pen tracks the surface’s ink so it cannot draw invisible strokes on a dark page, and stops the moment a colour is picked. Existing ink is never touched either way.

ios,android,windows,macos,linux,blazor
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 what the file looks like — which is why both leave it alone and theme only the surround. A notebook page was never printed and has no canonical appearance; it is the app’s own writing surface, and a dark app with a white page reads as a control that missed the memo rather than as fidelity. Existing ink is never recoloured, though: a stroke written in black stays black when the app flips to dark, because repainting a user’s ink is not theming.

Choreios,android,windows,macos,linux,blazor
The shape-text records moved to Shiny.Controls.Office.Text. ShapeParagraph, ShapeTextBody, TextAnchor and ShapeTextLayout sat in the Presentation namespace because that is where they were written, but they were already shared by the Word drawing side and are now shared by the notebook as well — a a:p means the same thing in a p:sp on a slide, in a wps:wsp inside a document, and in a container on a page. Source-compatible for anyone who had both namespaces imported; a file that only imported Presentation needs using Shiny.Controls.Office.Text; added. The Skia text painter was extracted alongside it as ShapeTextPainter, so the slide painter and the notebook painter draw a text body through one implementation rather than two — a caret positioned by the shared layout and glyphs placed by a second, separate painter would drift apart the first time either changed.

Featureios,android,windows,macos,linux
Every word a picker dialog draws is a property now. DurationPicker takes Title, HoursPickerTitle, MinutesPickerTitle, HourUnitText, MinuteUnitText, DoneText and CancelText; DurationPickerCell takes PickerTitle, DoneText and CancelText; and ColorPickerButton, FontPickerButton and FontSizePickerButton each take DoneText. Those strings — “Select Duration”, “Hours”, “hr”, “Done”, “Cancel” — were literals assigned in the constructor, which is the one place an app cannot reach: a control saying Done in English inside an app that says Fertig everywhere else had no route to it short of forking the control. Each string is an independent bindable property rather than one strings object, so it binds to a .resx lookup exactly the way every other property on the control binds — the shape Walkthrough already uses for NextText/BackText/SkipText/FinishText. Every default is the literal the control already rendered, so an app that sets none of them is unchanged. KeyboardDismissItem also stops overwriting a Text that was already set on its way to defaulting to “Done”. See DurationPicker, ColorPicker and FontPicker.

Fixios,android,windows,macos,linux
Tapping outside ColorPickerButton’s popup kept the colour you were dragging through. The picker live-writes SelectedColor on every spectrum drag, hue move and opacity change — that is what makes the button behind it preview the colour as you go — and the backdrop tap ran the same Close() as Done, so both routes committed and nothing in the control ever discarded anything. Dismissing a dialog by tapping away from it is a cancel everywhere else in the app, and the presence of a Done button is itself the promise that there is a way not to press it. The colour is snapshotted when the popup opens now, and the backdrop reverts to it through SetValue(SelectedColorProperty, …) — the same route a caller’s own assignment travels — so a two-way binding, the ColorChanged event and the button’s own swatch all see the revert rather than only the internal field. Done is untouched: it accepts whatever is live. See ColorPicker.

Fixios,android,windows,macos,linux
ColorPickerButton ShowOpacity="False" did nothing, while binding the same False worked. The button defaults ShowOpacity to false and the ColorPicker it wraps defaults its own to true, and the only thing that carried the value across was the wrapper’s propertyChanged callback — which MAUI does not raise when the value being set equals the one already there. So the literal, being the default, was a no-op that left the inner picker on its own default and the opacity slider on screen; a binding resolved later and moved the value, so it fired the callback and worked. The two are re-synced once at construction, which is where a wrapper’s state should agree with its child regardless of what any property system did or did not raise. The defaults themselves are deliberately different and unchanged — a compact trigger button that hides opacity and a full standalone picker that shows it are both right for what they are. See ColorPicker.

Fixios,android,windows,macos,linux
A disabled tool lit itself back up when tapped. KeyboardNavigationItem dims itself to 0.35 opacity at either end of a field run, where there is nothing to move to. Tapping it correctly did nothing — Invoke() returns early when IsEnabled is false — but the press-flash animation ran regardless, and its closing FadeToAsync(1, 90) left the arrow sitting at full opacity. The single thing a user got back from tapping a dead control was the control claiming it was alive, and it stayed that way. MAUI’s TapGestureRecognizer disregards IsEnabled on several platforms (dotnet/maui #18995, #20833, #23453 — the last of which recommends this exact guard), so the tap arrives whatever the state; the flash is skipped when the tool is disabled. It is on the shared IconTextTool base, so it covers the keyboard accessory items, the tools docked inside a TextEntry and the Quick Entry prompt tools alike.

Choreios,android,windows,macos,linux
The obsolete MAUI animation extensions are gone from the controls. FadeTo, ScaleTo and TranslateTo are deprecated in favour of FadeToAsync, ScaleToAsync and TranslateToAsync — same signatures, same behaviour — and the last three callers (Toast, ImageViewer and the scheduler’s calendar sheet) now use the Async names. Nothing changes at runtime; it keeps a build from source warning-free.

Fixios,android,windows,macos,linux
ImageViewer draws its lightbox on the page, not on whatever Grid it found. The host lookup ended in “the page’s root content, if it happens to be a Grid”, which failed in both directions: a page whose content was anything else — <ContentPage><ChatView/></ContentPage>, the shape of our own sample — had no host at all and threw into a task nobody awaits, so opening an image silently did nothing; and a root Grid with more than one cell got a full-screen overlay dropped into cell (0,0), covering a corner of the window. An explicit OverlayHost or ShinyContentPage still wins where one is present; everything else now uses a page-wide PageOverlay layer, ordered above a walkthrough and below a dialog. The wrapper is installed when the viewer loads rather than when it opens, because creating it re-parents the page’s content and doing that on the tap would reset the message list’s scroll position.

Fixios,android,windows,macos,linux
Tapping a photo in ChatView left a second copy of it pinned over the conversation. An ImageViewer is two things — the thumbnail you place and the lightbox it raises — and the chat wanted only the second, but it hosted the whole control stretched across every row. Setting Source on a tap therefore painted that photo full-bleed over the message list, and because the viewer stops being InputTransparent once it has an image, that copy also swallowed every touch. It had no close button and never cleared, so the chat stayed behind it for the rest of the session. The hosted viewer is invisible now: it is in the tree to find the page, not to draw.

Fixblazor
A ModalView close that Closing vetoed was a dead click. The panel already shook when a backdrop click was refused because CloseOnBackdropClick is false, and did nothing at all when the same click — or Escape, the ✕, or a footer button — was refused by the Closing handler, which is the case where the user actually did ask for something. Both are blocked dismissals and both nudge now. Worth pairing with a reason the user can read from inside the panel: a handler that writes its refusal to the page is writing it behind the modal that just refused to move. See Modal.

Featureios,android,windows,macos,linux,blazor
Find, on all three Office controls. 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, so 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 — and the arrows wrap at either end, 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. MatchCase and WholeWord are on the controller rather than adding two toggles to the bar, and whole-word uses the same rule double-click selection does, so don does not match don't. Finding changes nothing, so it stays live in a read-only editor. See Find in Office Documents.

ios,android,windows,macos,linux,blazor
What each of the three searches 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 a count including those hits would promise something “next” could never step to. PowerPoint searches the whole deck but only the shapes the slide itself owns: a hit inside a layout or master shape would count the company name once per slide and step the user into something they cannot select. Excel searches the cell text as the formula bar shows it — the formula when there is one, otherwise the literal, which is Excel’s look in: formulas and the only choice under which searching SUM finds the cells that total something; a formatted value would make 1234 miss a cell showing 1,234.00. The workbook walk is opt-in via SearchAllSheets, collects matches in book order rather than active-sheet-first — ordering around the showing sheet re-orders the list every time “next” crosses a boundary, which walks two sheets forever and never reaches the third — and never steps onto a hidden sheet.

ios,android,windows,macos,linux,blazor
Every hit is washed amber; the one you are on is drawn as the selection instead. Stacking the two washes was the first attempt and it made the current match a muddy blend of them — the hardest one on the page to pick out, which is the opposite of what it is for. The painters take the match rectangles from the controller and the test is what the selection actually covers rather than which match is active, so clicking away from a hit brings its amber back instead of leaving a gap in the highlights. Only what is on screen is measured: a search over a long document turns up hundreds of hits, and laying every one of them out per frame would put the cost of the whole document into each repaint. PowerPoint draws highlights for the showing slide only and Excel washes whole cells rather than the matched characters, because a cell is the smallest thing a selection can address.

Featureios,android,windows,macos,linux,blazor
The motion icon set goes from 42 icons to 111. Sixty-nine new ones, 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 (check-circle, x-circle, help, hourglass) — and they are grouped in the docs as Actions, Navigation, Objects, Media, Files, Weather and Indicators. The 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 deliberately matched sets: every arrow travels the way it points and pulls its shaft in behind the head, and 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. See Motion Icons and the icon set.

ios,android,windows,macos,linux,blazor
New artwork is checked by rendering it, not by reading it. Three of the sixty-nine were wrong in ways no assertion catches: a cloud whose closing arc had a radius a hair short for its chord, so the lobe collapsed into a straight diagonal; a collapse glyph whose four brackets met close enough to the centre to read as a plus; and a folder whose body redrew its own top edge, leaving a full-width rule under the tab. All three compiled, passed every unit test, and looked like a typo only once the whole set was rendered as a sheet — once through the Blazor SVG compiler and once through Microsoft.Maui.Graphics’ own path parser, which is the pair that catches a path a browser accepts and MAUI silently drops.

Featureios,android,windows,macos,linux,blazor
The spreadsheet ribbon has a Data tab. The bar was one tab holding six groups, and the split is now by what a command changes rather than by how often it is reached: Home changes how a cell looks — Clipboard, Font, Alignment, Number, Editing — and Data changes the shape of the sheet under it — Cells, Columns, Functions. That is what let the structural half grow past its ceiling. On one tab there was room for insert-row and insert-column between clear-formatting and a colour picker, and no more; delete rows and columns, column widths, hiding columns and the individual aggregates all existed on SpreadsheetController with nothing on the bar to reach them. Now: delete rows and columns beside the insert pair whose icons they mirror; a Width split button that fits the selected columns to their contents and offers four fixed widths — including the sheet’s own default, the only way back once a column has been dragged — behind its chevron; hide and unhide columns; and a function library giving SUM, AVERAGE, COUNT, MIN and MAX a button each, labelled with the formula name because the button writes =AVERAGE(…) into a cell. Home gained the indent pair next to the alignments, since indent is a cell format rather than a structural edit, and clear contents beside clear formatting. AutoSum is on both tabs, as it is in Excel. On Blazor ShowTabs now defaults to true — a strip carrying one “Home” was noise, but it is the only way to reach a second tab; setting it false folds the Data groups back onto the single tab rather than removing them. See Spreadsheet.

Featureios,android,windows,macos,linux,blazor
A marching-ants border marks what is on the clipboard. Excel’s dashed marquee, drawn around the cut or copied range and animated so it reads as a pending action rather than a second kind of selection — which is why it is a distinct colour rather than a dashed version of the selection green: the two are routinely on screen at once, since marking a source and then moving to a destination is the whole shape of a paste. SpreadsheetController.ClipboardRange is what the painter draws, and it is deliberately null when the capture came from another sheet — the content is still pasteable, but those coordinates mean something else over here and outlining them would be a lie. Escape clears it, and so does typing, deleting, a structural insert, or the paste that spends a cut. The colours are SpreadsheetTheme.ClipboardBorder in both schemes. See Spreadsheet.

ios,android,windows,macos,linux,blazor
Moving cells means rewriting every formula that named them, on every sheet. A row insert that only renumbers the cells leaves =B5 reading the blank row it just pushed in — nothing fails, the totals are quietly computed from the wrong place. FormulaReferenceShifter rewrites the formula text rather than reprinting a parsed tree, so whitespace, function casing and how a number was written all survive untouched. It does the two jobs that look alike and are not: a copy moves only the relative half of a reference and leaves $-pinned parts alone, while an insert moves absolute and relative alike, because the cells themselves have moved. Deleting turns a reference into the removed band into #REF! and keeps the sheet prefix — =Sheet2!#REF!, as Excel writes it — and undo restores those formulas verbatim rather than leaving a sheet of #REF! behind an undo that appeared to work.

Featureios,android,windows,macos,linux,blazor
Cut, copy, paste, insert row and insert column on the spreadsheet. Whole rows and columns, not just cell ranges: select a row or column header and the cut or copy takes the band with its values, formulas and formatting, and the paste lands as one undoable step. InsertRows opens blank rows above the selection and InsertColumns opens columns to its left, both shifting formulas and merged ranges to follow. The ribbon carries them in a Clipboard group that leads the bar, as it does in Excel, and a Cells group — which moved to the Data tab, alongside DeleteRows and DeleteColumns, when that tab arrived. See Spreadsheet.

Featureios,android,windows,macos,linux,blazor
The Spreadsheet, Document Editor and Slide Editor toolbars are Ribbons. All three were a single scrolling strip of two dozen icons separated by anonymous hairlines; they are titled groups now — Font / Alignment / Number / Editing on the spreadsheet, Font / Paragraph / Insert / Page in Word, Slide / Font / Paragraph / Insert in PowerPoint — with undo and redo in the quick access row where they never move. The tab strip is 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 spreadsheet has two tabs and shows it. See Spreadsheet, Document Editor and Slide Editor.

blazor
The hand-rolled dropdowns became real ribbon items. The spreadsheet’s number formats is a RibbonMenuButton and its AutoSum a RibbonSplitButton, keeping the live samples — Currency $1,234.50, Scientific 1.23E+03. That deleted a backdrop div, an absolutely-positioned panel and a bool …Open field per menu, and with them a second implementation of dismissal, keyboard handling and edge-flipping that the ribbon already had.

Featureios,android,windows,macos,linux
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 — every command ended up behind a dropdown, worse than the strip it replaced. Three controls wanted the same rule, so it belongs to the ribbon rather than being copied into each host. A collapse the user asked for is never overridden.

Fixios,android,windows,macos,linux
The slide counter sat above the arrows either side of it. Pinning every small-item row to one height — the fix that got the ribbon groups lining up with each other — makes a hosted view be the row, so VerticalOptions has no spare room to centre it in and a Label draws its text at the top of its own box while the icon buttons beside it centre their glyphs. VerticalTextAlignment is what the label actually needed. Blazor was never affected; its flexbox centres the strip already.

Featureios,android,windows,macos,linux,blazor
Page orientation. Layout ▸ Page carries Portrait and Landscape — 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, which are two halves of the same change: swapping without the attribute gives a page the right shape that Word still calls portrait, so Word’s own control shows the wrong state and the next change flips it the wrong way; writing the attribute without swapping gives a section claiming landscape on portrait paper, which Word obeys by re-swapping on open. Margins are left alone, as Word leaves them. Undoable, and it survives a save and reopen. See Document Editor.

Featureios,android,windows,macos,linux,blazor
Watermarks, on the viewers as well as the editors. Watermark takes a picture drawn behind the content — a logo, a DRAFT stamp, a company mark — and it is on all six controls, so a document opened read-only shows its mark too. It defaults to a 0.15 wash, because the failure people actually hit is one drawn at full strength that makes the page unusable. Drawn per page in print layout and once behind the viewport in reflow, since reflow has no pages and a mark that scrolled with the content would slide away and leave most of the document unmarked; clipped to the surface, since a rotated mark scaled to the page is wider than the page across its diagonal. Each editor has a Watermark button that picks a picture through exactly the same path as inserting one — camera or gallery on a phone, the platform’s own image-filtered dialog on a desktop — and clears the mark once one is set. It is a display watermark: drawn, not written into the file. That is a deliberate limit rather than an omission — Word keeps a VML shape in the header part, Excel has no watermark at all and fakes it with a header-and-footer image, and PowerPoint expects a picture on the slide master, so persisting to all three means three unrelated mechanisms where drawing means one. See Document Editor.

Fixandroid
The ribbon’s tabs were invisible until the bar was collapsed and re-opened. The strip is built in the constructor, before the control has a handler — and on Android every label came out of that first pass zero pixels wide and was never measured again. The band was there, correctly sized and correctly coloured, with nothing in it, which reads as a ribbon that has lost its tabs rather than one that has not been laid out. The strip is re-measured once the handler attaches.

Fixios,android,windows,macos,linux,blazor
Hovering an inactive tab made its label disappear in a light theme. The same mistake as the selected tab, one state further along: hovering lifts a tab off the coloured band onto a surface tint, and it was keeping the band’s white ink. One place decides the ink now, for all three states — built, selected and hovered — because each was written separately, which is exactly how hover was left behind when selection was fixed.

Fixblazor
A picker opened inside a ribbon was cut off by the surface below it. The ribbon body scrolls horizontally so a wide tab can be reached, and CSS makes an element a clipping context on both axes the moment either one stops being visible — so a 371px colour panel opening out of a 95px bar was clipped to almost nothing. The ribbon already raises its own menus into the browser’s top layer to escape this; the pickers are hosted content and were not its menus. ColorPickerButton, FontPickerButton and FontSizePickerButton now mark their panels so any clipping host can raise them, and the ribbon watches for one appearing — their own components re-render without the ribbon knowing, so there is nothing to hook. The attribute is inert everywhere else. Note that each panel’s explicit display is now load-bearing: the UA stylesheet gives every [popover] display: none until it is shown, so a panel without one would be invisible anywhere it is not inside such a host.

Fixios,android,windows,macos,linux,blazor
White tab labels on a light tab. The accent’s ink is what reads on the coloured header band, and it was being given to every tab — including the selected one, which is lifted out of the band onto the body’s own surface. In a light theme that is white text on a near-white tab: present, and unreadable. The selected tab takes the theme’s ink now, the rest take the band’s.

Featureios,android,windows,macos,linux,blazor
Each Office control wears its own colour. Accent paints the ribbon’s header band, its tab ink and the underline, and defaults to the colour Microsoft’s own application wears — Excel green #107C41, Word blue #185ABD, PowerPoint red #C43E1C. That is a default rather than a sample setting on purpose: a user reads those colours as “spreadsheet” and “slides” before any label has been looked at, and a workbook and a deck open side by side want telling apart rather than matching. It is also the one part of an Office control’s appearance deliberately not taken from the app’s theme — everything else follows the host’s neutrals. Set Accent for your own brand, or null to leave the bar on the theme. OfficeAccent.From(colour) picks the ink, because a caller choosing a brand colour is not thinking about whether their tab labels have gone invisible on it. Ribbon.HeaderForegroundColor is new on both hosts and is what makes a saturated header legible.

Featureios,android,windows,macos,linux,blazor
Page numbering, headers and footers, page breaks and print layout are on the document ribbon. All four were already in the controller and reachable only from code — the bar had no way to any of them. Insert now carries Objects, Header & Footer and Breaks; Layout carries the print/reflow toggle beside Margins and Zoom. The page number is a menu rather than a button, because a number has a place and a form and picking those afterwards means finding the header you just wrote into — and it appends to a header already there rather than replacing it, so adding a number does not silently delete the title. Header and footer are asked for rather than edited in place: they are separate parts of the document, laid out per page and repeated, so editing them in the canvas would mean a second caret and a second selection for what is usually one line. DocumentEditorController.ChromeText is new, and reads the current line back so the prompt opens on what is already there. See Document Editor.

Featureios,android,windows,macos,linux,blazor
Shapes are a ribbon 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, and it has to be dismissed before the result can be seen. They are a Shapes tab now — Rectangles, Basic, Arrows — and 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. The gallery, its grouping and its names are shared by both editors and both hosts, replacing a MAUI array and a Blazor gallery that were kept in step by a comment saying they had to match. See Document Editor and Slide Editor.

Featureios,android,windows,macos,linux,blazor
Page margins are on the ribbon. Normal, Narrow, Moderate and Wide are four buttons in Layout ▸ Margins 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.

Featureios,android,windows,macos,linux,blazor
The slide editor’s toolbar has tabs too. Home is the slide you are on and the text on it — Slide, Font, Paragraph; Insert is what goes on it — a text box, a shape, a table, a picture, and behind a rule the way to remove the selected one. Two tabs rather than more, because the deck has nothing to fill a third with: a slide is a fixed artboard always scaled to fit the viewport, so unlike a document page it is never clipped and there is nothing to pan to or zoom in on. See Slide Editor.

Featureios,android,windows,macos,linux,blazor
The document editor’s toolbar has tabs — two of them. Home is what you do to the text under the caret: Font, Paragraph and Proofing. Layout is what you do to the page it sits on: Page Setup, Insert and Zoom. It was tried with four, 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 rather than a Review tab of its own, because spelling is something you do while writing rather than a separate pass. See Document Editor.

Featureios,android,windows,macos,linux,blazor
Zoom, and a fit-width that makes a page readable on a phone. Zoom was a property with no way for a user to reach it. Now: pinch on touch, ctrl-wheel on the desktop — which is not a shortcut anyone had to learn, but what a trackpad pinch is delivered as in every browser — and a Zoom group on the Layout tab stepping 50 / 75 / 100 / 125 / 150 / 200 / 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. Print layout only; reflow already fits by construction.

Fixios,android,windows,macos,linux,blazor
A document could not be panned sideways at all. The viewport had one axis. A page is a fixed width — that is what makes it a page — so on anything narrower the right-hand end of every line was simply unreachable, and vertical panning scrolled through a document that still could not be read. DocumentViewport has a horizontal axis now, PageX is derived from it so hit-testing, the caret and the painted page move together, and a one-finger drag pans both ways. A wheel with a sideways component pans on the desktop.

Featureios,android
Spelling suggestions on the keyboard accessory bar. The red underline was the whole of what a phone user got: 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. While the caret sits inside a misspelling the corrections now appear above the keyboard — a real InputAccessoryView on iOS, a bar anchored above the IME on Android — with Ignore and Add beside them. It shows only while there is something to correct, so it costs nothing the rest of the time, and ShowSpellingSuggestions="false" turns it off. See Document Editor.

Featureios,android,windows,macos,linux,blazor
Step through a document’s misspellings from the toolbar. Home ▸ Proofing turns the pass on or off and walks the errors in either direction, selecting each word and opening its menu — a complete review loop without knowing the long-press gesture. The walk spell-checks each paragraph as it reaches it: the pass itself only ever covers what is on screen, since nothing off screen can show a squiggle, so a walk that trusted the cache stepped through a document full of misspellings and reported that it had none.

Fixios,android
Inserting a picture sent you to the file browser. On a phone that is wrong twice over: photos live in the gallery rather than the filesystem, and the picture someone wants in a document is often one that does not exist yet — they mean to take it. The button now asks: Take Photo, Photo Library, Browse Files, with the camera offered only where the platform reports one. Mac Catalyst is deliberately excluded — it runs the iOS code but presents as a desktop. An iOS host needs NSCameraUsageDescription and NSPhotoLibraryUsageDescription.

Fixmacos
The Picture button did nothing on the AppKit head. MAUI Essentials has no FilePicker for net10.0-macos — it covers iOS, Android, WinUI and Mac Catalyst — so the call threw, the catch swallowed it, and the button was silently inert. It opens an NSOpenPanel there now. On every desktop head the dialog is filtered to exactly the formats a document can embed, built from the same map the drop handler and the Blazor picker use, rather than the platform’s broader idea of an image: offering HEIC and then refusing it after the user has chosen is worse than not offering it.

Fixios,android,windows,macos,linux,blazor
The painted Office surfaces follow the theme’s neutrals, not just its light/dark bit. The grid, the page and the deck are drawn rather than composed from themed views, so their colours had to arrive as values — and those values were a fixed pair of palettes, a neutral grey and a white, while the chrome around them followed the app’s tokens. In any theme whose neutrals carry a tint (the packs here run blue) that put a blue-grey ribbon directly on top of a flat grey grid, close enough to read as a mistake rather than a choice. The grid now takes its background from Surface, its text from OnSurface, its grid lines from OutlineVariant and its headers from SurfaceContainer and Outline. Only the neutrals: the selection green and the clipboard marquee’s blue carry meaning rather than surface, and an app’s accent is no substitute for either — a spreadsheet with a purple selection is not a themed spreadsheet, it is a different control. A document and a deck take only their surround, since the page and the slides are pictures of printed things and tinting the paper would misrepresent them. Setting Theme still overrides all of it. See Spreadsheet.

Fixios,android,windows,macos,linux
The formula bar stopped following the selection once it had been typed in. The bar refuses a controller update while its field is being edited — otherwise an arrow key in the grid would overwrite a half-typed formula with the contents of whatever cell it landed on — and it clears that flag when the field loses focus. A canvas is not focusable, though, so tapping a cell left the field holding first responder and no Unfocused ever arrived: from the first time the bar was touched it ignored every selection after it, and the grid moved while the address and contents sat still. It reads as a formula bar that has simply stopped working, with nothing to say why. Touching the grid now ends the edit explicitly, committing what was typed into the cell that was being edited rather than the one tapped. Blazor was never affected — a browser blurs the input on the same click.

Fixios,android,windows,macos,linux
The spreadsheet’s ⋯ button put a dialog in front of the dialog it was for. Tapping it opened an action sheet whose only entry was “Sheet actions…”, which opened the menu that actually has Rename, Duplicate, Move, Hide and Delete on it — two taps and two sheets to reach a list that fits in one, unless a sheet happened to be hidden. Unhiding is a sheet action like any other, so it is in the same list now rather than in a menu above it. Blazor was already a single flat menu and is unchanged.

Featureios,android,windows,macos,linux,blazor
The spreadsheet and document editor can be panned with a finger. Both surfaces read a drag as “extend the selection”, which is right for a mouse and left touch with no gesture to scroll with — a phone has no wheel, so on the two editable surfaces there was no way to reach a column off the right-hand edge at all. Under touch they take the mobile convention instead: a tap selects a cell or places the caret, a drag pans, and a selection is extended by dragging the round handles drawn on its ends. Nothing changes for a mouse — drag still extends, the wheel still scrolls, and the handles are not drawn, since for a mouse they would be two targets that do nothing a drag does not. 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 or a laptop with a touchscreen. A press on a header still selects and resizes under touch: row and column selection is what cut, copy and insert operate on. See Spreadsheet and Document Editor.

Fixios,android,windows,macos,linux,blazor
A grid could be flung into empty space it could not come back from. Nothing in a sheet’s metrics stops a scroll — it is a million rows by sixteen thousand columns whether or not anything is in them. That is survivable with a wheel, which moves a notch at a time, but a finger flings, and a sheet scrolled into an unbounded field of blank cells with no way back is indistinguishable from one that has lost its data. Scrolling is clamped to the used range plus one screen of slack, which is enough room to type below or to the right of the data without there being everywhere to go.

Featureios,android,windows,macos,linux,blazor
A ribbon that scrolls now says so. Groups fold into buttons when a tab is wider than the bar, but where collapsing is off — or the collapsed groups still do not fit — the body scrolls instead, and a scrolling bar looked exactly like one that did not: the last group ended flush at the edge with nothing to say another followed. Both hosts draw a fade on whichever edge still has content past it, and drop it once that edge is reached. The platform scroll indicator is not the answer here: it is hidden deliberately, and on iOS and Android it only appears once a scroll is already under way, which is after the moment the user needed to be told. See Ribbon.

Fixios,android,windows,macos,linux,blazor
Ribbon groups did not line up with one another. Every group lays out its own columns, and rows were sized to whatever that group held — so the Font group, holding a 32px picker, grew only its own rows while the Alignment group beside it stayed at 24, and no two groups’ buttons sat on the same line. The group captions landed on different baselines as a result, which is what made the Office bars read as ragged: each group was correct on its own. Rows take one fixed height now, set with Ribbon.SmallItemRowHeight on MAUI and the --shiny-ribbon-row-h custom property on Blazor, and all three Office bars pin it at 32px. On Blazor the property has to be set on the .shiny-ribbon element rather than a wrapper around it — the ribbon declares the same property in its own rule, which beats an inherited value. See Ribbon.

Fixios,android,windows,macos,linux
A picker given a WidthRequest drew at its minimum and left the rest as a gap. FontPickerButton, FontSizePickerButton and ColorPickerButton shrink-wrap their trigger so that, dropped into a stack with no width of its own, it does not stretch across the row — but that left a host pinning a width with the width it asked for on the element and a trigger still at the minimum inside it. The spreadsheet’s font picker asked for 150px and drew 80, putting 70px of dead space between it and the size picker next to it; nothing overflowed and nothing was clipped, so it read as stray padding. The trigger fills a width that was explicitly requested now, and still shrink-wraps when none was. See Font Picker and Colour Picker.

ios,android,windows,macos,linux,blazor
Two small things in the Office bars that read as mistakes. The slide counter sat after both arrows — ‹ › 1/3 — which made the arrows look like two commands with an unrelated label beside them; it is between them on both hosts now. And the spreadsheet’s number-format menu carried the currency icon already sitting two buttons to its left in the same group, so the same mark appeared twice in one group; it is label-and-chevron only, as it is in Excel.

Fixblazor
Hosted content in a ribbon group was clipped to 96px and overflowed its neighbours. .shiny-ribbon-item.is-large caps at 96px, which is right for an icon over a label and wrong for arbitrary hosted markup: the spreadsheet’s 132px font picker was laid into a 96px column, centred, and spilled 18px each side straight over the bold button. Hosted content is exempt from the clamp now, and takes one row rather than spanning them — a row-spanning host is centred across the rows, so a 30px picker floated in the middle of a 76px column while the buttons beside it sat on the rows.

blazor
A component’s scoped CSS does not follow its class onto a child component. Moving .sheet-toolbar from a div in SpreadsheetToolbar.razor onto the Ribbon’s CssClass put it on an element carrying the ribbon’s scope id, so every .sheet-toolbar ::deep … rule in that file silently stopped matching and the buttons fell through to whatever the host app styles a bare <button> as. The class stays on a wrapper declared in the component’s own markup.

Featureios,android,windows,macos,linux,blazor
The Image Editor’s toolbar is a Ribbon. The tools were already grouped the way a ribbon wants them — a tool picker, a per-tool options row, a row of history and view commands — so most of this is a change of container. One part is genuinely better for it: the options are a contextual tab now, captioned Drawing Tools / Shape Tools / Text Tools, rather than an unlabelled strip that changed shape under the buttons that caused it. Home carries Tools, Shapes, Image and the host’s own actions; View carries zoom; undo, redo and reset sit in the quick access row, outside the tabs, so they never move when the tab does. ToolbarTemplate still replaces the whole bar. See Image Editor.

ios,android,windows,macos,linux,blazor
Below 600px the ribbon runs Simplified — one dense row, every item small, group titles dropped. An expanded ribbon is about a quarter of a phone screen, and this control’s whole job is to show the picture underneath it. Every item is declared Small rather than the ribbon’s Large default, for a second reason too: Simplified keeps a label only on items declared small, so a mix of sizes rendered a row where some tools were labelled and some were bare icons with no rule a user could see.

Fixios,android,windows,macos,linux,blazor
Collapsing a Simplified ribbon and re-opening it gave you the full one. ToggleCollapsed restored Expanded unconditionally, so on a narrow window the chevron was a one-way trip: the dense single row could be put away but never came back, and what returned was a bar three times the height of the one just collapsed. It restores the mode it collapsed from now — on both hosts — and a DisplayMode set any other way is remembered the same way.

Fixios,android,windows,macos,linux,blazor
A host that rebuilds its ribbon was losing the collapse. The Image Editor rebuilds its toolbar on every tool and property change, which means a fresh Ribbon each time, each one starting at the mode its width implied — so collapsing the bar and then picking a tool simply opened it again. It carries the mode across the rebuild now, and a width rule no longer overrides a choice the user made by hand.

ios,android,windows,macos,linux
A checked ribbon item now carries a ring as well as a fill. SecondaryContainer is a near-neighbour of the bar’s own surface in a dark scheme — present, but not readable as “this one is on” at a glance, which for a tool palette is the whole job of the checked state.

Fixios,android,windows,macos,linux
The editor’s undo, redo and reset icons were hooks. Each was a short arc segment plus a two-line head, a shape that only held together at the size it was designed against; at the ~20px they are actually drawn they collapsed into a mark that read as neither an arrow nor a curve. They are explicit Bézier paths now, which are the same at every size and on every platform.

BREAKINGios,android,windows,macos,linux
The MAUI Ribbon moved into the core package. It shipped in Shiny.Maui.Controls.Desktop, whose target frameworks stop at the desktop ones — which put it out of reach of every control in the core package that might want it, since core cannot reference the add-on that references it. It is in Shiny.Maui.Controls now and targets iOS and Android with everything else; the namespace moved with it, from Shiny.Maui.Controls.Desktop.Ribbons to Shiny.Maui.Controls.Ribbons. XAML is unaffected — the ribbon was always mapped onto the http://shiny.net/maui/controls URI, so shiny:Ribbon reads exactly as before and only a C# using needs changing. Blazor already had it in core and does not move. See Ribbon.

ios,android,windows,macos,linux
The ribbon was never insulated from an implicit Style. Moving it into core put it in scope of the scan that catches this, and it failed immediately: its propertyChanged callbacks touched children that MAUI had not built yet, because an implicit style is applied from StyleableElement’s own constructor. The StyleGuard.MarkReady call at the end of its constructor was already there — nothing was routed through StyleGuard.WhenReady to replay, so it did nothing.

Fixios,android,windows,macos,linux
The Image Editor’s toolbar scrim was white, not black. Color.FromRgba(20, 20, 22, 0.86f) binds to the all-float overload — the ints widen, and channels there run 0-1, so 20 clamped to 1. The “dark scrim” was painted white, and with the bar’s white icons and labels on top of it the whole toolbar read as an empty strip. Nothing warns; the call is perfectly legal.

Fixios,android,windows,macos,linux,blazor
Dark mode is now the theme’s job, not the app’s. Every colour default across both control sets is a theme token, so a control dropped into a dark app is dark with nothing wired up. The three that were worst — the DataGrid, the Office controls and the toolbars — were each broken a different way, and all three are fixed. See Styling & theming.

Fixblazor
A colour default that arrives as an inline style cannot be themed away. ShinyToolbar shipped BackgroundColor="#FFFFFF" and TextColor="#1F2937", ShinyTabBar the same, SheetView a white sheet, SecurityPin a white cell, SchedulerCalendarView a white day, ChatView a pale green and white bubble pair, and DocumentEditorView/SlideEditorView a white formatting bar. Each is emitted straight onto the element, where it beats every stylesheet — so no theme, and no amount of app CSS, could correct any of them. They are all var(--shiny-color-*) references now; passing your own value still pins it, exactly as before.

Fixblazor
<select>, checkboxes and scrollbars follow the theme. The generated theme never declared color-scheme, so every native widget stayed in the browser’s light palette — a themed dark toolbar hosting a stark white font-size dropdown. It is now declared on the same scope that carries the colour tokens, and because color-scheme inherits it works whether the theme class sits on <html> or on a container div. A .shiny-theme-light scope is emitted to match, so a deliberately-light region inside a dark app resolves correctly instead of inheriting the dark tokens.

Fixios,android,windows,macos,linux,blazor
The Office surfaces follow the host’s scheme instead of defaulting to white. SpreadsheetView, DocumentView, DocumentEditor, SlideView and SlideEditor all had a .Dark theme and all defaulted to .Light, so a workbook in a dark app rendered a stark white sheet under a white toolbar and the host had to notice and set Theme by hand. Theme is nullable on both hosts now and unset means follow the host — the app’s appearance on MAUI, the page’s color-scheme on Blazor — live, when that flips. .Light and .Dark still pin, which is how a preview stays paper-white. SlideTheme.Dark continues to darken only the surround: a slide is an authored artboard and inverting it would misrepresent the deck.

blazor
A canvas cannot inherit a colour, and matchMedia is the wrong question. The Skia-backed surfaces need the scheme as a value, and the obvious source — the OS preference — is wrong for the common case, since a Blazor app rarely owns <html> and flips its theme with a class on a container instead. The scheme is read from the element’s own computed color-scheme and watched for change, which covers the class toggle and the OS preference with one mechanism.

Fixios,android,windows,macos,linux
The host app’s implicit Button style no longer reaches inside a control. The .NET MAUI project template ships a <Style TargetType="Button"> whose Disabled visual state sets BackgroundColor to Gray600, and it applies to every Button in the process — including the flat glyph buttons a DataGrid pager and a sheet tab strip are built from. In dark mode the disabled first/previous buttons were the only ones with a background, and the tab strip’s add and overflow buttons wore the app’s brand colour. Internal parts now carry their own CommonStates group and express disabled as opacity; a locally-set attached property beats one arriving through a style. Implicit styles targeting the Shiny control types themselves are unaffected.

Fixblazor
Hover states that were invisible on a dark bar. The toolbar, the Office bars and the sheet tab strip washed hover with a fixed rgba(0, 0, 0, 0.06), which does nothing over a dark surface, and marked the active state with a hardcoded blue rather than the theme’s primary. Hover is now keyed off the bar’s own currentColor, so it reads on a dark bar and on a brand-coloured one alike.

blazor
--shiny-surface is not a token. The prefix is --shiny-color-*, and several stylesheets — the Captcha, the splash screen, the formula bar, the spreadsheet toolbar — asked for the short form. A misspelt custom property is not an error: it falls through to the literal fallback beside it and the control simply never follows the theme, in a way that looks identical to having no theme support at all. All of them are corrected, along with --shiny-font-family and --shiny-radius-*, which had the same problem.

Fixblazor
Markdown follows the theme pack, not just the scheme. MarkdownTheme gains Themed, whose every colour is a Shiny token, and that is what an unset Theme now resolves to. MarkdownTheme.Light and .Dark remain literal palettes for a preview that has to look the same whatever the app is doing.

Fixios,android,windows,macos,linux
TableView section headers follow the theme, and stop going stale. The header band, its title, the footer text and both separators were literal iOS system greys picked from RequestedTheme while the section was being built. A theme pack restyled every other part of the table and left the headers in iOS grey — and because the colour was read once, an appearance flip after that first render left the old value on screen, which is what put a near-black band between the sections of a light table. They are bound to theme tokens now and re-resolve on both. Section titles are also drawn uppercase and tracked, matching the Blazor TableView; HeaderTextTransform and HeaderCharacterSpacing control that, and because it is a TextTransform the bound Title string is untouched. See TableView.

Fixios,android,windows,macos,linux
The Markdown editor’s toolbar follows the theme too. Same shape of bug: the bar, its separators and its buttons were two hardcoded greys chosen once from RequestedTheme, so a light editor could sit under a near-black formatting bar after a theme change.

Fixblazor
A calendar event’s label is derived from its own colour. Event chips carry a colour the app picks per event, so no single ink works for all of them — white disappears on a pale amber, near-black disappears on a deep indigo, and a theme token is right only by coincidence since the chip does not follow the theme. The label colour is computed from each chip’s luminance instead, which holds in both schemes.

Featurewindows,macos,linux,blazor
A Ribbon — the Office-style tabbed command bar — on desktop MAUI and Blazor. Ribbon → RibbonTab → RibbonGroup → items, authored declaratively on both hosts: nested elements in XAML, nested components in Razor. Items are RibbonButton, RibbonToggleButton (two-way checked state), RibbonSplitButton (the face runs the default action, the chevron opens the menu), RibbonMenuButton, RibbonSeparator, and RibbonContentItem/RibbonContent for hosting any view or markup you like — a picker, a combo, a swatch strip. Dropdowns nest into submenus, groups can offer the small corner dialog-launcher arrow, and a quick access row of icon commands sits beside the tabs. It ships in Shiny.Maui.Controls.Desktop on MAUI and in the core Shiny.Blazor.Controls package on Blazor — the same split docking uses — and needs no registration on either. See Ribbon.

windows,macos,linux,blazor
Nothing declares a column. A Large item takes one to itself and Small items stack three deep in a shared one, with a separator or a large item starting a fresh column — so a group is re-flowed by reordering its items and nothing else. That is the whole of a ribbon’s layout language. On Blazor it falls straight out of grid-auto-flow: column, which fills a column top to bottom before moving across.

Featurewindows,macos,linux,blazor
Contextual tabs are one property and a binding. ContextTitle captions the coloured band above the strip and marks the tab contextual; binding its visibility to whatever the tab is about — a picture is selected, a table is selected — is what makes it appear. When the showing tab stops being selectable the ribbon falls back to the nearest one that still is, so a vanished selection never leaves an empty body. A contextual tab underlines in the tertiary accent rather than the primary one, so it reads as a different kind of thing rather than the selected one of the same kind.

Featurewindows,macos,linux,blazor
The bar gives up whole groups, worst first, rather than dropping buttons. A tab wider than the window folds groups into single buttons that open the group in a popup — lowest Priority first, rightmost breaking ties — because half a group is worse than a closed one. CanCollapse="false" pins one open, AllowGroupCollapse="false" scrolls the body instead. DisplayMode is two-way and takes Expanded, Collapsed (strip only; picking a tab peeks the body back and the next command puts it away) or Simplified (one dense row, every item small, group titles dropped).

blazor
A cascaded value marked IsFixed freezes the children that read state off it. IsFixed="true" is the right call for a reference that never changes — but it also skips the subscription, so a child is re-rendered only when one of its own parameters changes. A ribbon button whose parameters are all strings is then never re-rendered at all: switching to the simplified layout left it drawn large for the rest of its life, while the split and menu buttons beside it — which carry a List parameter, so the diff always considers them changed — updated correctly. Same cascade, same parent, opposite result.

blazor
An inline SVG with no box is 300×150. The button a collapsed group folds into reuses the item icon wrapper but is not itself an item, so it missed the rules that give that wrapper a size — and the icon inside fell back to a replaced element’s default, making the button three hundred pixels wide. That then fed straight back into the overflow arithmetic, which measured it and folded away the rest of the bar.

Featureios,android,windows,macos,linux,blazor
Stop points and a vertical orientation for the Slider. SliderMark puts a labelled stop on the track at a fixed value, each with its own text and colour, as a dot, a tick, or a pill-shaped label. With SnapToMarks on — the default — the thumb comes to rest on the nearest one and Step steps aside; turn it off for marks that are only reference points, a target or a redline on an otherwise continuous slider. Orientation="Vertical" runs the track bottom-to-top, minimum at the floor, with the tooltip to its left and the labels to its right. On MAUI the marks are a Marks collection, on Blazor they are <SliderMark> children. See Slider.

ios,android,windows,macos,linux,blazor
A mark’s label never sits on the track. Snapping parks the thumb on a mark by definition, so a badge centred on the track at a mark’s value would spend its life underneath the thumb — which is exactly what a bubble did before it moved into the band beside the track. What stays on the track is the dot or tick, drawn under the thumb so a tick is not painted straight through it, and the first and last label slides inward so it does not hang off the end.

Fixblazor
The dropdown arrows in the Office toolbars are big enough to see. The font and font-size pickers — on the Document Editor, Spreadsheet and Slide Editor toolbars alike — marked themselves with a ▾ character, and the split buttons (highlight, cell fill, number formats, AutoSum) with a chevron drawn at 12px. Both came out as specks a few pixels wide. The pickers now draw the same stroked chevron the rest of the chrome uses, at 14px, and the split-button chevrons render at 16px. See FontPicker.

blazor
A triangle glyph is smaller than its font-size suggests. U+25BE fills well under half its em box, so a 10px caret drew an arrow about 4px across sitting beside a 13px label. On the font picker it was worse again: that trigger rewrites its own font-family to whatever family is selected, so the caret was handed to a font that had never been asked to draw one. An SVG fills the box it is given and inherits nothing but the colour.

Fixios,android,windows,macos,linux,blazor
The colour picker no longer opens crushed to a sliver. The popup ColorPickerButton puts up came out roughly the width of its hex box, with the spectrum squeezed to about a quarter of its size and the hue bar unusable — visible everywhere the button is used, the Document Editor, Spreadsheet and Slide Editor toolbars included. It is a 320px panel again on both hosts. See ColorPicker.

ios,android,windows,macos,linux,blazor
Nothing inside the picker had a width of its own. The spectrum, the hue bar and the opacity track are all told to fill, and each is a canvas on Blazor and a GraphicsView on MAUI — neither has content to measure, so each asked for zero. That is correct in a container that stretches, and wrong in one that shrink-wraps its content, which is exactly what the button provides: an absolutely positioned popover on Blazor, a centre-aligned Border on MAUI. The only child that measured was the bottom row. Blazor pins the popover at width: 320px capped at calc(100vw - 16px) — viewport-relative deliberately, because a percentage would resolve against the 30px trigger and change nothing — and MAUI sets a MinimumWidthRequest, not a WidthRequest, so the picker still fills a wider host.

Featureios,android,windows,macos,linux,blazor
Bulleted and numbered lists in the Word and PowerPoint editors. Two toggle buttons on DocumentEditorView and SlideEditorView turn every paragraph the selection touches into a bulleted or numbered item, with indent and outdent beside them. Tab in a list nests the item and Shift+Tab un-nests it — the only gesture there is for creating a second level — and typing - or 1. at the start of a paragraph starts a list without reaching for the toolbar. ToggleBulletList, ToggleNumberedList, SetListStyle, ChangeListLevel and HandleTab are on both controllers, and CaretFormat.List says which button to light. See Document Lists and Slide Bullets & Numbering.

ios,android,windows,macos,linux,blazor
The numbered levels compound, so the second level reads 1a. Level 1’s lvlText is %1%2., which restarts at 1a under item 2 — the label says which item it belongs to, and a bare a does not. That needed a correctness fix underneath it: each %n in a compound template now renders in the format of the level it refers to rather than always as decimal, which is what turns %1%2. into 1a. instead of 11.. Bullets cycle •, ◦, ▪ by level, written as the Symbol and Wingdings code points Word itself uses.

ios,android,windows,macos,linux,blazor
A Word paragraph does not carry its own bullet, so the first list in a document that has never had one creates the numbering part, a nine-level definition and the instance behind it. The definition is stamped with a fixed w:nsid so a second press reuses it rather than adding a near-identical abstract list — and, more to the point, rather than starting a second sequence that would make every numbered item restart at 1. ⚠️ Restarting a list part-way through a document, and choosing the glyph or number format, are still not offered; a document that already carries its own definitions keeps them in full and is only ever pointed at.

ios,android,windows,macos,linux,blazor
The level’s hanging indent is what the label is drawn in. A level whose w:ind is written as a direct child of w:lvl instead of inside its w:pPr compiles against the typed API, saves without complaint, and is then silently not found: the item is not indented and its bullet is painted on top of the first letter. The label text is right in both cases, which is why this only showed up in a browser.

ios,android,windows,macos,linux,blazor
Autoformat is deliberately narrow, and is one undo step. The marker has to be everything before the caret, so a hyphen mid-sentence is a hyphen and a lone letter never numbers a list. The marker and the space that triggered it are removed together, so one Ctrl+Z puts the typed characters back rather than leaving a bulleted empty paragraph behind. IsAutoFormatListEnabled = false turns it off. Enter on an empty list item ends the list — a nested item comes out one level first — and outside a list Tab is still a tab character rather than being silently swallowed.

Featureios,android,windows,macos,linux,blazor
Auto-numbered PowerPoint paragraphs show a real number. a:buAutoNum rendered as a bullet character before, whatever the deck asked for. The number is now counted per text body at the paragraph’s own outline level — two bulleted placeholders on one slide each start at 1 — and rendered in the file’s own scheme: arabic, alphabetic or roman, with a period, a trailing paren or both. See Slide Bullets & Numbering.

Fixios,android,windows,macos,linux,blazor
Indenting a multi-paragraph selection in a slide no longer flattens it. ShiftLevel read the outline level off the first paragraph in the selection and applied that one level to all of them, so a Tab over two paragraphs at different depths put both at the same depth. Each paragraph now moves relative to its own level. Only visible when a selection spans more than one line, which is why it survived.

Fixblazor
The document toolbar follows the caret. DocumentEditorView only re-rendered after an edit, so clicking into a bold paragraph or a bulleted item left the toolbar showing the formatting of wherever the caret used to be — and the list buttons unlit and the nesting buttons greyed out — until the next keystroke. DocumentEditor now raises SelectionChanged for caret and selection moves, the way SlideEditor already did.

Featureios,android,windows,macos,linux,blazor
Page margins are settable, from both toolbars and from code. A page-margins button on DocumentEditorView opens Word’s own four presets — Normal, Narrow, Moderate and Wide — as an action sheet on MAUI and a popover on Blazor, with the preset the document already matches marked. DocumentEditorController.SetPageMargins takes one of those, a PageMargins.FromInches(...), or four numbers; PageMargins and PageMarginPresets live in Shiny.Controls.Office.Shared, so the two hosts cannot disagree about what “Moderate” means. See Document Editor.

ios,android,windows,macos,linux,blazor
The undo captures the whole w:pgMar, not six numbers. That is what makes it total: a document that never had a margins element goes back to not having one, rather than to defaults the command would otherwise have to invent — and everything else the element carried survives, w:gutter above all, which a rebuilt element would silently drop from a document bound for printing.

ios,android,windows,macos,linux,blazor
Only the paginated layout can show it. DocumentPageLayout.Reflow has no paper to inset content from, so it keeps its cosmetic gutter; the margins are still written and still saved, exactly as a page break is. ⚠️ Margins are the last section’s — one page geometry is read for the document and one is written back, so multi-section documents are not modelled. Setting the paper size or orientation is still not offered.

Fixios,android,windows,macos,linux,blazor
Editing inside a numbered list no longer changes its number. Highlighting a word, typing in an item, or applying any formatting handed that item the number after the document’s last one, and every further edit pushed it one higher — undo included, which advanced it again rather than putting it back. List numbers now come from a pass over the block list in document order rather than from counters consumed once at read time. See Document Editor.

ios,android,windows,macos,linux,blazor
The number was never the paragraph’s to keep. It is a function of every numbered paragraph before it, so a paragraph re-read on its own — which is what happens after each edit, since the editor re-projects only the blocks it touched — cannot know it. The reader now records just the numId and level in ListLabel.Numbering and leaves ListLabel.Text empty; NumberingSequencer fills it in afterwards. WordNumbering is stateless as a result, which is also why headers and footers no longer continue the body’s numbering, or each other’s.

Featureios,android,windows,macos,linux,blazor
Lists renumber themselves. Splitting an item, deleting one or dropping a block into the middle of a list now moves everything after it, which nothing did before — the number was resolved once when the document was opened and then frozen into the paragraph.

Featurewindows,macos,linux,blazor
IFileDropService — files dropped anywhere on the app window, including over a BlazorWebView. MAUI gets it in Shiny.Maui.Controls.Desktop via .UseFileDrop(); Blazor gets it in the core package via AddShinyFileDrop() plus one <FileDropHost /> in the layout. Four events — DragEnter, DragOver, DragLeave, Dropped — extension / size / count filters that decide what your code ever sees, and an optional IFileDropDelegate for drops that should be handled the same way whatever page is showing. On MAUI desktop a dropped file arrives with its real path; in a browser it arrives as a stream, because the browser will not give one. See File Drop.

windows,macos,linux,blazor
This exists because DropGestureRecognizer cannot do it. That gesture is per-view, is unimplemented on the AppKit and GTK4 heads, is broken on Mac Catalyst (dotnet/maui#23627), and — the reason a new service was needed rather than a helper around it — sits behind hosted web content. A WebView2 / WKWebView / WebKitWebView is its own drop target and wins over anything underneath, usually by navigating itself to the dropped file, so an app whose UI is a BlazorWebView never saw the drop at all. SuppressWebViewDrop (on by default) is what takes the web view out of the way, and it is the first switch to turn off if hosted web content starts behaving oddly.

macos
On AppKit the drop target becomes the window’s contentView, with MAUI’s content as its subview. AppKit resolves a drop by hit-testing and then walking up the superview chain, which rules out the obvious implementation twice over: a transparent overlay that returns nil from hitTest: is never found by the search, and one that does not swallows every click in the app. Becoming an ancestor is the only placement that both receives the drag and stays out of the way of the pointer.

windows,macos,linux,blazor
A wholly refused drop raises DragLeave, not Dropped. No platform sends a leave after a drop, so a drag overlay bound to the drag state would otherwise stay up for good the first time someone dropped the wrong file type. RejectedCount says how many were filtered out. Related: while a drag is still moving, browsers hide file names and sizes and Mac Catalyst has only a suggested name — so bind an overlay to Files.Count / HasAcceptableFiles rather than to a name.

blazor
The browser’s File objects are released once your handler returns. They live in JS memory until then, so a page that takes several large drops and never lets go grows without bound. ReleaseFilesAfterHandling = false keeps them for a read you do later, and ReleaseAsync(files) lets go when you are done.

Featureios,android,windows,macos,linux,blazor
A formatting toolbar for the Spreadsheet. ShowToolbar puts a SpreadsheetToolbar above the formula bar on both hosts. Two halves: the usual one — font, size, bold, italic, underline, strikethrough, text colour, alignment on both axes, wrap text — and the half only a spreadsheet has: cell fill, number formats, decimal places, AutoSum, fit-column-to-contents and clear formatting. Every button is one undoable command through the same SpreadsheetController a keyboard shortcut would reach, so a toolbar action and a typed edit share one undo stack. ⚠️ It is off by default, unlike the formula bar and the tab strip — those are how a workbook is read, and a viewer should not grow a formatting bar it never asked for. See Spreadsheet.

ios,android,windows,macos,linux,blazor
Formatting is a delta, not a format assigned wholesale. CellFormatChange names only what changes, which is what lets bolding a range that mixes a red heading with black body text leave both colours where they are. The alternative — resolving one format and stamping it across the selection — silently flattens everything the user had.

ios,android,windows,macos,linux,blazor
Styles are interned, and that is not an optimisation. A cell stores one index into the workbook’s cellXfs table, not its formatting, so bolding a thousand cells adds exactly one entry to the styles part rather than a thousand identical ones. Without it the styles part grows without bound across a session of ordinary editing. Workbook.StyleWriter.Intern is the public entry point; existing entries are only ever appended to, never rewritten, so a workbook opened from disk can be formatted without disturbing the cells nobody touched.

Featureios,android,windows,macos,linux,blazor
Auto formulas. Σ writes SUM over the range the selection implies, and its split button also offers average, count numbers, min and max. The rules follow Excel, and they are the whole feature — one cell selected totals the run of numbers immediately above it, or failing that the run to its left; a single row or column puts the total just past the end, or into its last cell when that cell is empty; a block gets one total per column in the row underneath. A cell already holding one of those aggregates ends the run, so a second total under an existing one does not silently count everything twice. Pressing it with nothing to total writes nothing rather than leaving =SUM() behind.

Featureios,android,windows,macos,linux,blazor
Formatting a whole column, from its header. A column-header selection is written as a column style — one attribute on one <col> element, the way Excel does it — rather than as a million cell styles. That is what makes a column formatted as currency still show currency for a value typed into it tomorrow. Row-header selections work the same way, and a cell’s own style still wins over its row’s, which wins over its column’s. ⚠️ Read a cell’s format with Worksheet.GetEffectiveStyleIndex, not GetStyleIndex: the plain getter returns only the cell’s own style, so a cell formatted through its column comes back as unformatted.

Fixios,android,windows,macos,linux,blazor
Column widths and row heights are saved. Dragging a column-header edge only ever moved the in-memory grid metrics, so the width was lost the moment the workbook was saved and reopened. The drag now commits a SetColumnWidthCommand (undoable like any other edit), and the toolbar’s fit-to-contents button and SetColumnsHidden write to the file as well.

Fixblazor
The grid resolves fonts through the shared registry, so bold and italic cells render. SpreadsheetPainter called SKTypeface.FromFamilyName directly, and on WebAssembly there are no system fonts at all — that call returns the same embedded fallback for every family and every weight, and never null. A bold cell rendered in regular with nothing anywhere to say why, and the whole grid painted in one wrong face. It now goes through the same SkiaTextMeasurer the document painter uses, which consults the application’s registered faces first and carries the substitution table that turns a Calibri request into the bundled Carlito.

Featureios,android,windows,macos,linux,blazor
One icon set across the Word and PowerPoint toolbars, on both hosts. Every plain button on DocumentEditorView and SlideEditorView now draws from a single monochrome stroked set defined once in Shiny.Controls.Office.Shared, on a 24x24 grid at one weight — MAUI paints it onto a GraphicsView, Blazor writes it out as inline SVG stroked in currentColor. What it replaced was a mixture of styled letters (B, I, U, S), geometric unicode (⯇ ≡ ⯈ ↶ ↷) and emoji (🖼, 🗑). See Document Editor and Slide Editor.

ios,android,windows,macos,linux,blazor
The emoji were the reason it had to change, and not on taste. A font paints emoji in its own colour, size and weight, so the picture and delete buttons could not be tinted, did not dim with a disabled button and looked different on every platform. Geometric unicode has the milder form of the same problem, plus tofu on Android fonts that lack the character. Stroking in currentColor (or the theme’s on-surface token on MAUI) is what lets one icon follow the toolbar, the theme and the disabled state.

ios,android,windows,macos,linux,blazor
The geometry is drawing commands, not an SVG path string. MAUI’s PathBuilder has real gaps parsing a d attribute — implicit line-tos become move-tos, run-together decimals truncate — and it throws nothing, so artwork authored as a path string can look perfect in a browser and draw a stump on a device. Neither host parses anything now: the shapes arrive as Move/Line/Cubic commands plus rectangle and ellipse primitives, and each host draws them with its own natives.

ios,android,windows,macos,linux,blazor
The pickers are the deliberate exception. Font, font size, text colour and the highlight swatch have to show what they are currently set to, which is the one thing a monochrome icon cannot do — so they keep their previews. The highlight split button takes the shared A-over-a-bar artwork and tints only the bar with the colour it would apply.

Featurewindows,macos,linux,blazor
Icon-only toolbar buttons carry a tooltip on desktop and web. Each is wrapped in Shiny’s own Tooltip naming what it does, rather than the browser’s title — which is slow to appear, cannot be themed and is unreachable from a keyboard. ⚠️ On MAUI this is off on iOS and Android: the tooltip opens on hover and there is no hover on a touch screen, and a long-press tooltip would compete with the tap the button exists for. ShowToolbarTooltips on DocumentEditorView and SlideEditorView overrides either way; off on Blazor falls back to the native title. The accessible name (aria-label / SemanticProperties.Description) is set regardless — a tooltip is not what a screen reader reads.

ios,android,windows,macos,linux
On MAUI the toolbar buttons are no longer Buttons. A MAUI button can only hold text, and it consumes touch natively without ever routing it to its GestureRecognizers — which both the tap and the hover tooltip need. They are Borders around a GraphicsView now, sharing one internal factory between the two editors instead of a copy each. Public API is unchanged.

Featureblazor
ModalView — a modal window for Blazor. A titled panel over a backdrop that owns the screen until it is dismissed. Every region is optional and replaceable: a Title/Subtitle/Icon or a HeaderTemplate or no header, the built-in ✕ or a CloseButtonTemplate or no close button, your content, and a footer of ModalButtons or a FooterTemplate or neither. Two-way IsOpen plus ShowAsync/CloseAsync/ToggleAsync, five sizes, three placements, six animations and a blurrable backdrop. See Modal.

blazor
Closing is the veto, and Closed says who did it. Closing is raised before anything unwinds and cancels the dismissal when a handler sets Cancel — the dirty-form guard, and it covers every route out (backdrop, Escape, close button, footer button) rather than only the one you remembered to wire. Closed then carries a ModalCloseReason: CloseButton, Backdrop, Escape, Button or Programmatic. ⚠️ Binding IsOpen to false is the one path that skips the veto: the page has already decided, and asking it again would fight the binding.

blazor
The modal contract is not optional. Focus moves into the panel on open — the first focusable element, or whatever carries data-shiny-autofocus — and Tab is trapped there; the page behind stops scrolling, with the scrollbar’s width replaced by padding so the layout does not lurch sideways; focus returns to whatever had it; and the panel is role="dialog" aria-modal="true", labelled by its own title. Modals stack, and the stack is what makes that work: the newest sits on top, Escape reaches only the topmost, and the scrollbar comes back only when the last one closes.

Featureblazor
It is a window when you ask it to be. Draggable moves it by the header (header buttons stay out of the drag surface, so grabbing near one still presses it), Resizable gives it a bottom-right grip, and AllowMaximize / ShowMaximizeButton / MaximizeOnHeaderDoubleClick maximise and restore it. Maximising drops any drag offset and resized size, so restoring lands where the stylesheet says rather than wherever the panel happened to be left.

blazor
A footer button’s OnClick is awaited before the close runs. So “save, then close” is ClosesModal = true and an async handler — no IsOpen juggling. ClosesModal = false keeps the modal up instead, which is what a button that flips its own State to Busy while the work runs wants. ⚠️ Keep the ModalButton list in a field: a list rebuilt in a render expression throws away the state (State, Disabled) the buttons are carrying.

blazor
Where to put it. ModalView needs no host component and no DI registration — unlike Toast, Dialogs or ProgressLine, it renders where you place it. Keep it at page level, out of any ancestor carrying transform, filter or contain: those create a containing block for position: fixed and would trap the panel inside that element. There is no MAUI equivalent — FloatingPanel and the dialog service cover the same ground there.

Featureios,android,windows,macos,linux,blazor
PasswordStrength — a password field with a live strength meter and a rule checklist. Both hosts, core packages, built on top of TextEntry, so the floating label, the tool slots and the hint line are the ones you already have. A segmented meter (or a single bar), a Weak/Fair/Good/Strong caption, a checklist that shows every rule whether or not it is met, a Show/Hide toggle, and a Warning surfaced as the field’s own hint text. See PasswordStrength.

ios,android,windows,macos,linux,blazor
The defaults are passphrase-first, and that is the opinionated part. MinimumLength is 15, RequireNotCompromisedPassword is on, and every character-composition rule — uppercase, lowercase, number, symbol — is off. A “must contain a symbol” rule does not produce strong passwords, it produces Passw0rd!; NIST SP 800-63B dropped composition rules for that reason and so do these defaults. They are all still there for when an external policy forces your hand.

ios,android,windows,macos,linux,blazor
⚠️ Gate your submit button on IsAcceptable, never on Score. They answer different questions and they genuinely disagree: Score/Level say how hard the password is to crack, IsAcceptable says whether the policy is satisfied. A forty-character passphrase scores 100 and still fails a rule demanding a digit, so Score >= 80 accepts passwords your own rules reject. On MAUI Score, Level, IsAcceptable and Result are OneWayToSource bindables; on Blazor they are read-only properties reached through @ref.

Featureios,android,windows,macos,linux,blazor
Scoring is a pluggable async interface, so zxcvbn or Have I Been Pwned can replace the built-in heuristic. IPasswordStrengthEvaluator resolves from the control’s own Evaluator, then DI (SetCustomPasswordStrengthEvaluator<T>() on both hosts), then the built-in. It is asynchronous and cancellable precisely so a network-backed implementation is possible: keystrokes are debounced by DebounceMilliseconds (250 by default) and the previous evaluation is cancelled before the next starts. If a custom evaluator throws, the built-in one answers instead — losing the network downgrades the meter rather than freezing it on a stale verdict. ⚠️ Never send the password, or its full hash, anywhere: HIBP’s range API takes the first five characters of the SHA-1 hash and returns a bucket of suffixes exactly so neither has to leave the device.

ios,android,windows,macos,linux,blazor
The built-in scorer charges nothing for the parts an attacker gets free. Entropy is collapsed-length × log2(character pool), where collapsing removes runs of the same character, ascending and descending sequences, and everything past the first repetition of a repeated block — so sixteen as are worth about two characters rather than sixteen. Any word from the built-in breached list is then charged a flat 11 bits instead of its length, and a password that is one of those values is scored as the bare word, seen through case, leet substitution and a bolted-on year (P@ssw0rd2024 lands with password). No network, no data files. The list is deliberately short rather than a bundled ten-million-line corpus — CommonPasswords.IsCompromised and CommonPasswords.FindLongestMatch are public so a custom evaluator can reuse it.

ios,android,windows,macos,linux,blazor
UserInputs splits an email before it looks for it. ada.lovelace@example.com is broken on its punctuation, so a password merely containing lovelace is refused — and the matched run is discounted when scoring rather than only flagged. Fragments under four characters are ignored, because they match half the dictionary and would fail every password. BlockedPasswords is the simpler sibling: exact, case-insensitive matches for the house list.

blazor
The Show/Hide toggle is words, not an eye, and the meter announces itself. An eye emoji renders at a different size on every platform, some Android system fonts have none, and neither reads correctly to a screen reader — ShowPasswordIcon/HidePasswordIcon take your own icon font when you want a glyph. On Blazor the meter is a role="progressbar" with aria-valuenow and an aria-valuetext of the current level, the caption is aria-live="polite", and every checklist row appends a visually hidden “, met” / “, not met” — a coloured tick alone does not say which.

Featureios,android,windows,macos,linux,blazor
Expander and Accordion — a disclosure panel and the list that coordinates a stack of them. A header you tap and content that animates in beneath it, on both hosts, in the core packages. Header text plus an optional detail line or a header view of your own, an indicator that rotates or swaps on either edge, two-way IsExpanded, cancelable Expanding/Collapsing on MAUI, and LoadContentOnDemand so a list of twenty expanders over twenty forms builds one form rather than twenty. See Expander.

ios,android,windows,macos,linux,blazor
Animation is a flags enum, so the three reveals combine rather than compete. Fade, Slide and Height, defaulting to Height | Fade. Height is the one that makes an accordion read as an accordion — the panel grows and shrinks between zero and the content’s size, so everything below it moves with the reveal instead of being uncovered by it. ⚠️ Animation="Slide" on its own therefore pops the layout open and then slides the content within it; Height,Slide is nearly always what was meant. SlideFrom aims the slide at Top, Bottom, Left or Right and does nothing while the Slide flag is off, and ExpandDirection="Up" puts the content above the header — what a panel pinned to the bottom of a page wants, with the indicator turning the other way so it still points at where the content actually is.

blazor
On Blazor the whole reveal is CSS — no JS interop and no measuring. The panel is a grid transitioning grid-template-rows between 0fr and 1fr, which is the one height transition that does not need a pixel value computed first. So content that resizes while the panel is open still lays out normally, rather than being clipped by a height measured before it grew. prefers-reduced-motion is honoured. The header is a role="button" element with aria-expanded/aria-controls answering Enter and Space, and a collapsed panel is inert and aria-hidden so nothing inside it can be tabbed into.

ios,android,windows,macos,linux
On MAUI Height measures the content and animates a clipped panel — and stands down when there is nothing to measure. The very first reveal can happen before the expander has been laid out, where the only options are to snap the panel to a guessed height or to skip that one effect. It skips: fade and slide still carry the transition, and every reveal after the first has a real measurement. Once open the panel returns to auto height, so content that grows later is not clipped for good.

Featureios,android,windows,macos,linux,blazor
Accordion decides how many may be open, and refuses to be empty when told to. SelectionMode is Single or Multiple; AllowCollapseAll="False" opens the first item in a list that starts closed and takes the collapse affordance away from whichever item is the last one open, giving it back the moment something else opens. Two-way ExpandedIndex, ExpandedIndexes, ExpandAll/CollapseAll/ExpandItem, and item events carrying the expander, its data and its index. An item that closes because another opened raises nothing of its own — that close is a consequence of the tap, not a second event. See Accordion.

ios,blazor
The default indicator glyphs carry U+FE0E, and that is not decoration. CollapsedIcon and ExpandedIcon default to "\u25B6\uFE0E" and "\u25BC\uFE0E" — the triangles plus the Unicode text-presentation selector. Without it both iOS and WebKit draw U+25B6 as the glossy blue play-button emoji rather than a text triangle, which is what the control looked like the first time it ran on a simulator. Keep the selector on any replacement glyph that has an emoji form.

ios,android,windows,macos,linux
On MAUI, an AutomationId on the expander also names its header row. The tap gesture lives on that internal row rather than on the expander, so UI automation driving the expander’s own id resolves an element with nothing to tap and silently reports failure. Setting AutomationId="Shipping" now also sets Shipping_Header on the row that actually opens it — the suffix is the constant Expander.HeaderAutomationIdSuffix.

ios,android,windows,macos,linux,blazor
The accordion’s motion and chrome properties are defaults, not overrides. They reach every item that did not set the same property itself, so one odd expander in a styled list stays odd — and changing the accordion’s value later still reaches the items it seeded, which a naive “only if unset” check would have blocked after the first push. Items can be written out one by one, generated from data, or both; the generated ones are appended after whatever was declared in markup. MAUI takes ItemsSource with HeaderTemplate/ContentTemplate (or an ItemTemplate returning a whole Expander) plus an ItemStyle for the rest; on Blazor a plain @foreach of <Expander> inside the accordion keeps the models strongly typed, with an Items parameter there for when the shape is only known at runtime.

Featureios,android,windows,macos,linux,blazor
DataGrid: summary (total) rows, under the grid and inside every group. SummaryRows holds any number of rows, each a set of cells pointing at columns. A cell either aggregates its column (Sum/Count/Average/Min/Max/Custom) or simply fills its slot with a label — which is the whole point: a right-aligned “Total” in one column and the number in the next, without a template. A column with no cell is left blank, and rows stack in declaration order, so a subtotal / tax / total block is three rows rather than one hand-built footer. See Grouping & Summary Rows.

ios,android,windows,macos,linux,blazor
The same declarations serve the grid’s footer and every group. A group is a set of rows, so there was no reason for a group total to be declared separately from a grid total — each renders the same row against a different set of items. Scope="Grid"/"Group" narrows one to a single side. An aggregate with no StringFormat is formatted the way its column’s own cells are, so the total under a currency column is currency without repeating the format — except a Count, which is always a plain number, because a currency column would otherwise report twelve rows as $12.00. ⚠️ The older per-column Aggregate/FooterTemplate still works and still produces the single footer row it always did; declaring any SummaryRows takes over from it.

Featureios,android,windows,macos,linux,blazor
GroupSummaryPlacement decides where a group’s totals sit — including above its rows. Footer (the default) puts them after the group’s rows, where they collapse along with what they total. Header puts them directly under the group’s title, so the totals stay on screen while the group is collapsed — which turns a fully collapsed grid into a summary table of one line per group. Both does both, None leaves groups plain.

Featureios,android,windows,macos,linux,blazor
DataGrid grouping is now multi-level, and GroupBy is the whole state. A list of columns, outermost first — <shiny:DataGrid.GroupBy> or GroupBy="{Binding}" on MAUI, @bind-GroupBy on Blazor — with each level indenting from its parent and every header carrying the count of all the rows beneath it. The header’s ⊞ button appends to (and removes from) that same list rather than owning a second copy of the state, and numbers its glyph once more than one level is in play. GroupSortDirection orders the groups (ascending by default), GroupsInitiallyExpanded sets whether they start open, ExpandAllGroups()/CollapseAllGroups() drive them from code, and GroupHeaderTemplate replaces a header’s content.

ios,android,windows,macos,linux,blazor
Groupable now only means “the user may re-group this”. Grouping happens whenever GroupBy has an entry, so a view model can drive it with the header buttons switched off — previously the interactive switch also gated the grouping itself, which made a declared grouping silently do nothing. ⚠️ Collapse state is keyed on a group’s path, not its key: with two levels, the “West” under one department is independent of the “West” under another, where a key-keyed set would collapse both at once. Paging stays skipped while grouped — a page boundary would slice a group in half.

Featureios,android,windows,macos,linux,blazor
ProgressLine — the thin loading line across the top or bottom of the window. A sibling of ProgressBar rather than a mode of it: ProgressBar fills a slot you gave it in a layout, ProgressLine is chrome with no slot that pins itself to a page edge. It composes a ProgressBar internally, so the gradient, the shimmer sweep, indeterminate mode and the animated fill are the same code — and every platform paint fix applies to both. Declare it with Value bound, or drive it from code with IProgressLineService and no markup on the page at all. See ProgressLine.

ios,android,windows,macos,linux
On MAUI, a line declared in markup moves itself onto the page edge. It does not render where you wrote it — it removes itself from that layout and installs into the page’s overlay layer on the edge named by Position. Surprising exactly once, and the whole point of the control; Dock="False" keeps it inline for a header you are drawing yourself. ⚠️ OnParentSet alone could not drive this: XAML constructs the line and adds it to its layout first, and only then hands that layout to the page, so the line’s own parent never changes at the step where the page appears. The control watches the top of the chain it is in for a parent of its own, which is the difference between working and silently sitting inline forever.

ios,android,windows,macos,linux
One rule resolves the inset past the navigation and tab bars: a bar earns an offset exactly when it is painted inside the same coordinate space the line is. So a ShinyTabBar docked over a Shell page pushes the line up by its height, while ShinyNavigationPage, ShinyTabbedPage, a native NavigationPage and a native TabbedPage all resolve to zero — their content area already excludes their chrome, and an inset there would be a visible gap rather than clearance. Measured height beats the declared BarHeight wherever it exists, because ShinyTabBar.RespectSafeArea folds the home indicator into the bar’s own height and adding a safe-area inset on top would count it twice. AutoInset="False" opts out; Offset stacks on top.

Featureios,android,windows,macos,linux,blazor
Runs are reference-counted, and the line tells the truth about them. Two overlapping operations produce one line that stays up until the slower of them lands — not two lines, and not a line that vanishes when the first request finishes. The slowest run is the one shown rather than the average, because averaging lets a quick call drag the bar most of the way across while the slow one it is waiting on has barely started. Progress below the current value is ignored. With nothing reported the line trickles: it advances a fraction of the distance still to run each tick, decelerating toward TrickleCeiling (0.9) and never arriving — completion has to come from the caller, because a line that reaches 100% on its own has claimed the work finished when it has not. On MAUI the showing page is re-resolved every tick, so a run started before a navigation follows it instead of drawing onto a page the user has left.

Featureblazor
On Blazor it is position: fixed with env(safe-area-inset-*), plus two escape hatches. Anchor="Container" runs the line along the edge of the nearest positioned ancestor instead of the window — a panel, a card, a dialog — and a --shiny-progressline-offset custom property lets any ancestor push it below an AppLayout header without the component having to know that header exists. The service is registered Scoped, not Singleton: it owns the active run list, which is per-user state, and a singleton would have run one user’s loading line across every connected user’s window on Blazor Server.

Featureios,android,windows,macos,linux,blazor
ProgressBar’s fill now slides to each new value instead of snapping — in both directions. A value that drops drains back at the same rate it filled, rather than the fill-only easing most progress bars ship, which makes a downward correction read as a glitch. AnimateProgress, ProgressAnimationDuration and ProgressAnimationEasing configure it; 0 snaps. Only a value change animates — a width change driven by layout (the control being measured, TrackHeight changing, leaving indeterminate mode) snaps, because animating those makes the bar visibly reflow every time its container resizes. Blazor’s transition was previously hardcoded at 0.3s ease and is now the same configurable pair, and it honours prefers-reduced-motion. Retargeting mid-slide continues from where the bar actually is rather than jumping to the previous target.

Featureios,android,windows,macos,linux,blazor
Shapes, pictures and tables in the Word editor. InsertShape, InsertImage and InsertTable on DocumentEditorController, with a gallery, a drag-a-grid table picker and a picture button already in DocumentEditorView’s toolbar on both hosts. The twenty preset geometries are the same ShapeGeometry enum and the same path builder the slide editor draws — moved out of the presentation namespace into Shiny.Controls.Office.Shapes so a rounded rectangle is one shape rather than two implementations of one. See Shapes, Pictures & Tables.

ios,android,windows,macos,linux,blazor
Everything a document inserts is inline, and that is deliberate rather than unfinished. A wp:inline, never a wp:anchor: an object behaves like a very large character, wraps with its line and moves as text is typed before it. The document view is a reflow engine — one continuous column, no pagination, no fixed page positions — so an anchored object could be written to the file perfectly well and then never drawn near where it claimed to be, which looks right in Word and wrong here. ⚠️ An anchored drawing in a file you open is still read and shown in the flow at the point it is anchored from, and the unsupported note says so. A table goes after the caret’s paragraph rather than inside it, with a trailing empty paragraph, because OOXML cannot put a w:tbl inside a w:p and a body that ends with a table is a file Word offers to repair.

Fixios,android,windows,macos,linux,blazor
Every caret position after a picture was off by one. The layout engine has always counted an inline drawing as one character; the paragraph editor’s offset space skipped it entirely. So in any document containing an image, clicking after it put the caret in the wrong place and typing landed a character away from where it looked like it would — silently, and with no test to catch it because no fixture had a picture in one. A w:drawing now contributes U+FFFC to the offset space, and Split counts runs the same way the reader does.

Featureios,android,windows,macos,linux,blazor
Select and resize an inline object. Click one and a frame appears with eight handles: a corner keeps the aspect ratio, an edge changes one dimension, and the whole drag collapses to one undo step rather than one per pointer sample. BeginObjectDrag returns false when the gesture belongs to the text caret instead, which is what lets a host try objects first and fall through to ordinary click-and-drag selection without knowing anything about them. An object cannot be dragged to a new position — it is in the text flow, and the caret is what moves it.

Featureios,android,windows,macos,linux,blazor
Shapes, pictures and tables on a slide. AddShape, AddPicture and AddTable join AddTextBox, over the same galleries the Word toolbar offers. AddShape writes a real drawn shape rather than a text box — no TextBox flag on the non-visual properties — which is what makes PowerPoint give it the theme fill and treat it as a shape; a table is a p:graphicFrame with a built-in table style, not a shape at all. All four place in slide coordinates and select what they added, so the next gesture is a drag of the new object. See Shapes, Pictures & Tables.

Featureios,windows,maccatalyst,blazor
Drag an image file in from the desktop. Dropping onto a document inserts at the drop point; dropping onto a slide places it centred on the pointer, sized to at most half the slide. AllowFileDrop is on by default and DropRejected reports a file over 32MB or in a format OOXML cannot store — an event rather than a message the editor draws, because there is nowhere inside a canvas to put one that survives the next repaint. ⚠️ Blazor works everywhere; on MAUI it is Windows, iOS/iPadOS and Mac Catalyst only — Android has no file drag from a file manager, and the AppKit and GTK heads have no implementation behind DropGestureRecognizer. There the toolbar’s picture button is the gesture. The listener is on the canvas rather than the toolbar, so dropping a picture on the Bold button does nothing. SVG is deliberately not accepted: both formats can hold one, but only alongside a rasterised fallback Office generates itself.

Featureios,android,windows,macos,linux,blazor
Text highlighting in both editors, over one palette. A split button — the swatch applies the last colour used, the chevron opens the gallery or clears it — identical on both hosts and over both file types. The two formats disagree about what a highlight is: w:highlight takes a name from a closed list of sixteen, while a:highlight wraps an arbitrary colour. HighlightPalette is that list of sixteen, so Word writes the name and PowerPoint writes the RGB and nothing is approximated in either direction. Strikethrough was already there on both sides and stays.

Fixios,android,windows,macos,linux,blazor
Opening a .docx containing a highlight threw. w:highlight’s val attribute is in the w namespace and the style resolver asked for it in the empty one — which raises KeyNotFoundException rather than returning null, so the document failed to open at all. The property was modelled and the painter drew it; no fixture had ever carried one.

Featureios,android,windows,macos,linux,blazor
Shapes in the ImageEditor: rectangle, ellipse and circle, with fill and border. Drag corner to corner and the shape is one undoable action, replayed at export resolution like every other annotation. AllowRectangle, AllowEllipse and AllowCircle gate the three tools. The circle tool constrains the drag to a square, taking the smaller of the two extents rather than the larger – so the shape can never escape the bounds the drag was already clamped to. On Blazor, holding Shift applies the same constraint to the rectangle and ellipse, read live so taking or releasing it mid-drag is followed. See Shapes.

ios,android,windows,macos,linux,blazor
The border is the ink you were already annotating with; only the fill is new. A shape takes DrawStrokeColor and DrawStrokeWidth for its outline – the same settings the draw, line and arrow tools use – so switching tools keeps your colour and weight, and DrawStrokeWidth="0" leaves the shape unstroked. The interior is ShapeFillColor, and null – the default – draws the outline only, which is what you want over a photograph. Set a colour and the same tool becomes a redaction block. The toolbar mirrors that split while a shape tool is active: a fill swatch and a fill on/off toggle join the border swatch and the pen weights, and turning fill off keeps the colour so turning it back on restores what you picked rather than resetting to white. ShowShapeFillPicker="False" hides the pair.

blazor
Fill alpha reaches the two hosts differently, because the pickers do. On MAUI the fill’s ColorPickerButton shows its opacity slider, so the alpha lives in the Color itself. <input type="color"> has no alpha channel at all, so on Blazor the toolbar puts an opacity slider beside the swatch and the parameter pair is ShapeFillColor (a #rrggbb string) plus ShapeFillOpacity (0-1) – composed into an rgba() before it reaches the canvas. ⚠️ A circle is stored and rendered as an ellipse in a square box, so a crop applied afterwards that changes the image’s aspect ratio stretches it, exactly as it already stretches a freehand stroke. Crop first, annotate second.

Featureios,android,windows,macos,linux
A NavigationPage with toolbar items on both sides. ShinyNavigationPage is a NavigationPage — PushAsync/PopAsync/PopToRootAsync/InsertPageBefore/RemovePage, the modal stack, page lifecycle, Android’s hardware back button and Pushed/Popped/PoppedToRoot all keep working unchanged. What it adds is the thing no native bar has room for: items on the left as well as the right. Declare them on the page as ShinyNav.LeftItems/RightItems, in the same shape ToolbarItems already has. See NavigationPage.

ios,android,windows,macos,linux
The bar is drawn, because the left slot does not exist to be borrowed. On iOS, Android and WinUI alike, the leading slot of the native navigation bar belongs to the back button — and on macOS AppKit and Linux GTK4 there is no navigation bar at all. So the native bar is hidden and ShinyNavBar draws its own. Nothing in it touches a platform SDK, which is what makes the left items, the overflow menu, the badges, the motion icons and the collapsing large title render identically on every MAUI head rather than on the two that could have been coaxed into it.

Featureios,android,windows,macos,linux
NavBarItem derives from ToolbarItem, so your toolbar already works. Text, IconImageSource, Command, IsEnabled, IsDestructive, Clicked, Order and Priority mean exactly what they already mean; it adds motion icons, a Badge ("" is a dot, null is nothing), Display, IsVisible, IsSeparator and Tag. Both collections are typed ToolbarItem, and a page’s own Page.ToolbarItems are drawn on the right automatically, ahead of anything declared — so adopting the page is additive rather than a rewrite. Order="Secondary" folds an item into that side’s overflow menu however much room there is, and anything past MaxVisibleItems (3 per side) folds in behind it.

ios,android,windows,macos,linux
Everything MAUI already gives a NavigationPage is honoured, with one documented exception. Page.Title, SetHasBackButton, SetBackButtonTitle, SetTitleView, SetTitleIconImageSource, SetIconColor and the Bar* colours all keep working. ⚠️ SetHasNavigationBar is read only as the starting value: that property is the slot the class had to take over to hide the native bar, so a page writing false to it a second time is a no-op that raises nothing and cannot be observed. The runtime switch is ShinyNav.SetIsNavBarVisible(page, false), or IsNavBarVisible on the navigation page for all of them at once. For the same reason the appearance default is named BarIconColor rather than IconColor — MAUI’s IconColor is its attached per-page one, and a same-named static would have shadowed it.

Featureios,android,windows,macos,linux
An iOS-style large title that collapses, on every platform. LargeTitleDisplay="Collapsing" draws an oversized title beneath the bar row and folds it into the inline title as the page scrolls, cross-fading so the two are never both at full strength. It finds the first ScrollView or ItemsView in the page on its own; ShinyNav.ScrollSource names a different one. LargeTitleDisplay carries an explicit Inherit member so a single page can turn the large title off against a navigation page that turned it on — without it, a page setting None would be indistinguishable from a page that never said anything. ShinyNavBar.CollapseProgress is bindable, so a header image can parallax off the same value.

macos
What does and does not render on macOS AppKit. The bar, its items, the badges and the back button all draw on net10.0-macos — the wrapper is installed before the page is ever presented, which sidesteps the re-parenting problem that leaves the app-wide Flyout install blank on that head. ⚠️ Two things do not: the overflow menu paints nothing, because it is added to a page overlay layer on the tap that opens it and AppKit never realizes a child added to a page after it has been laid out — the same pre-existing limitation as Toast, Dialogs, in-app Quick Entry and ShinyTabBar’s centre menu — and a collapsing large title leaves a residual band under the bar at the end of the fold, because the row is not re-measured when the title is taken out of the layout. Prefer MaxVisibleItems="0" and LargeTitleDisplay="None" there.

ios
Swipe-back is put back deliberately. UINavigationController disables interactivePopGestureRecognizer whenever its bar is hidden — which is exactly what this page does — so adopting it would silently have cost you the edge swipe. The recognizer’s delegate is replaced rather than nulled: nulling it is the widely-copied trick and does re-enable the gesture, but it also lets a swipe start on the root page, where UIKit pops nothing and leaves the controller unable to respond to touches at all. EnableSwipeBackGesture="False" opts out. ⚠️ Shell cannot host a NavigationPage — a MAUI rule, not ours — so present this modally in a Shell app.

Featureios,android,windows,macos,linux
An improved TabbedPage, and the same bar on Shell. ShinyTabbedPage gives tabs motion icons, per-tab badges, a direction-aware transition between tabs, and content that is built the first time you reach it and then kept. ShinyTabBarBehavior drops the identical bar onto a Shell, where Shell keeps routing, deep links, lazily built ShellContent and each tab’s navigation stack and only the chrome is replaced. Nothing in it touches a platform SDK, so it also renders on macOS AppKit and Linux GTK4 — heads MAUI’s own TabbedPage does not reach. See TabbedPage.

Featureios,android,windows,macos,linux
A centre button that presents the page’s actions, not the bar’s. TabCenterButton is a raised circle in the middle of the bar that is deliberately not a tab — it never becomes the selection. What it presents is declared by whichever page is showing, in the same shape as ToolbarItems: ShinyTabs.Actions for a list of rows, or ShinyTabs.MenuContent/MenuContentTemplate for an arbitrary view. Mode="Menu" falls back to a plain click when neither the page nor the button declares anything, so a centre button that is only ever a button behaves like one without being reconfigured.

ios,android,windows,macos,linux
An adopted ContentPage does not get OnAppearing — implement ITabAware. A ContentTemplate may inflate a whole ContentPage, which is adopted: its Content is hosted, its Title fills in the tab and its BindingContext is mirrored across. But MAUI raises page lifecycle from the platform, for the page the platform actually presented, and an adopted page never is — so IPageController.SendAppearing() on it does nothing at all. Rather than call a method that silently does nothing, the contract is declared: ITabAware (OnTabAppearing/OnTabDisappearing) is called on the content, the page, or either one’s view model, each object once. An adopted page also has no place on a navigation stack; this.Navigation resolves but pushes onto the tabbed page’s stack.

Featureios,android,windows,macos,linux
A pushing flyout now shifts the content rather than crushing it. FlyoutView.PushMode defaults to Shift: the content keeps its full width and is simply translated aside, its far edge sliding out of the clipped view. Nothing inside re-lays out — text does not rewrap and columns do not collapse as the panel moves, which is both the drawer feel most apps want and far less work per frame than re-measuring the whole content tree, since it is the measure pass rather than the arrange that reflows. PushMode="Resize" keeps the previous narrow-and-reflow behaviour, which is what a responsive master/detail beside a permanent sidebar wants. ⚠️ The mode governs every displacement the view applies, the rail included — so a permanent rail in Shift mode pushes its own width of content off the far edge for good, and an app whose rail is chrome should use Resize. Two shifting panels cancel rather than crush: 280 on one side and 200 on the other give a net 80 shift.

Featureios,android,windows,macos,linux
The indicator travels between tabs. IndicatorTransition="Slide" — the default — moves one indicator horizontally from the tab it was on to the tab it is going to, shaped by IndicatorEasing and AnimationDuration. It is a different thing from Transition, which animates the content, and the two compose. ⚠️ Sliding is positioned from measured cell bounds, and before the first layout pass those are all zero — so the bar falls back to drawing the indicator inside each cell until it has a size, which is what keeps the first frame correct instead of parking a zero-width indicator in the corner. IndicatorTransition="None" keeps that per-cell drawing permanently.

Featureios,android,windows,macos,linux
The chrome is template-driven and the animations are yours. TabCenterButton.ContentTemplate replaces the circle, its background and its glyph outright; ShinyTabBar.MenuTemplate replaces everything inside the popup card while the bar keeps the backdrop, the anchoring above the button and the open/close animation. SelectionAnimation ships Scale, Lift, Bounce, Fade, Indicator and None, and ITabAnimator replaces the lot — called once per tab whose selected state genuinely changed, never on a restyle or a badge update, with the cell, icon, label and indicator handed over separately because lifting a whole cell moves the label with the icon. The bar also respects the bottom safe area by default: its background paints to the screen edge while the tabs sit clear of the home indicator.

Fixios,android,windows,macos,linux
A TabAction’s bindings now resolve. Command="{Binding Save}" on an action declared in ShinyTabs.Actions silently did nothing: the action lives in an attached-property collection, so it is never on the declaring page’s element chain and its bindings evaluated against a null context — producing a null command, and a row that closed the menu having done absolutely nothing. The bar now seeds the declaring element’s binding context onto actions and menu content as an inherited context, so an explicit BindingContext on the action still wins. ⚠️ Only caught on a device: a unit test that assigns Command directly rather than binding it never exercises the path.

ios,android,windows,macos,linux
Every tab addressable by name, and the centre button sits lower. Each cell carries an AutomationId of tab-<route> (falling back to tab-<title>), and the centre button carries tab-center — on the circle itself, because an id on an element with no gesture recogniser resolves and then does nothing, which is the most confusing possible outcome for a UI test. A tab with neither a route nor a title gets none rather than an index-based id that would point somewhere else the moment the order changed. Overhang also now defaults to a third of Size rather than a half: half centres the circle on the bar’s top edge, which is the textbook diagram but reads as floating away from the tabs it belongs to.

ios,android,windows,macos,linux
A badge belongs wherever the count does. On the ShinyTabItem or ShellContent when it must show on a tab the user has never opened — there is no page to ask yet — and on the page with ShinyTabs.Badge when the page computes it. The page’s value wins, but only for the tab that page is showing, so one page’s count never lands on every tab in the bar. Badge="" draws a dot; Badge=null draws nothing. ⚠️ ShinyTabs.Actions needs a <shiny:TabActionCollection> wrapper in XAML, the way VisualStateGroups needs VisualStateGroupList, and the Shell form re-parents page content — which does not re-render on the macOS AppKit head, so use ShinyTabbedPage there.

Featureios,android,windows,macos,linux
A flyout that collapses to a rail, picks a side, and pushes or floats. FlyoutPanel slides in from either edge and rests in one of three states — Hidden, Collapsed (a narrow icon rail showing RailContent) or Expanded — either pushing the content aside or floating over it with a scrim, chosen per panel or left to Auto at a width breakpoint. ShinyFlyoutPage replaces MAUI’s FlyoutPage; FlyoutView drops into any single page; and ShinyFlyout.StartTemplate/EndTemplate install one over every page a Shell or NavigationPage shows — which FlyoutPage cannot do at all. Drive it from a view model with IFlyoutService. See Flyout.

ios,android,windows,macos,linux
Presentation governs an expanded panel; a rail always insets. A collapsed rail is chrome rather than a drawer, so it takes its width out of the content on both presentations — which is what lets a rail expand over the content without the content shifting sideways on the way. CollapseBelow drops an expanded panel to its CollapsedState when the flyout gets narrow and restores it when there is room, measured against the FlyoutView rather than the window; it is a response to the viewport, not a preference, so it is never persisted and a deliberate state change always outranks it.

ios,android,windows,macos,linux
Sliding and resizing are not the same transition. Hidden ⇄ anything translates the panel at its final size — no layout pass, so a phone drawer stays smooth — while Collapsed ⇄ Expanded genuinely resizes and re-lays out the content beside a pushing panel each frame. Telling the two apart is why FlyoutView is a custom layout rather than a Grid with animated column widths. ⚠️ The app-wide install re-parents a page’s content, which does not re-render on the macOS AppKit head (net10.0-macos) — the same pre-existing limitation as Toast, Dialogs and in-app Quick Entry; use ShinyFlyoutPage or FlyoutView in the page’s own markup there.

ios,android,windows,macos,linux
The app-wide flyout is a DataTemplate, not an instance. Each page builds its own panel; sharing one across pages would re-parent it on every navigation, rebuilding its native views and throwing away scroll position and focus. What carries across is the state, so a drawer left open is still open on the page you land on. Pair it with Shell.FlyoutBehavior="Disabled", and note that the panel lives inside the page — so Push pushes the page content, not Shell’s nav bar or tab bar.

Featureios,android,windows,macos,linux,blazor
A .pptx editor, in two controls. SlideEditor is the lone editing surface — canvas, selection, caret, typing, no chrome. SlideEditorView is the same control wrapped in an editing toolbar and a status line. Both sit on a shared SlideEditorController carrying shape selection, move and resize with eight handles, in-shape text editing, run and paragraph formatting, bullet outline levels, add and delete text box, and undo/redo. See Slide Editor.

ios,android,windows,macos,linux,blazor
Two gestures, and the distinction is the whole design. A single click selects a shape and draws a dashed frame; a double-click puts a caret inside its text and the frame turns solid. That is the split PowerPoint uses, and the frame is the only cue telling a user where the next keystroke will go. Only shapes the slide itself owns can be selected — ones painted from the layout or master are skipped, because they belong to every slide using that layout and dragging one would move it across the whole deck at once. Shapes flattened out of a group are likewise not editable: their position is the group’s coordinate space collapsed into the slide’s, so writing a new one back would put them somewhere else entirely.

Featureios,android,windows,macos,linux,blazor
Slide edits are surgical on the runs, and the text layout is now shared with the painter. A run is split only where an edit needs a boundary and is never re-created, because an a:rPr carries language, hyperlinks, effects and theme-derived fills the model does not represent. The layout of text inside a shape moved into the kernel so the painter and the editor compute it once — a caret that agrees with the glyphs today but is computed separately drifts the first time either side changes. ⚠️ DrawingML is fussier than WordprocessingML about child order: a:rPr’s children are a sequence, not a set, and PowerPoint refuses to open a file whose run properties are out of order rather than offering to repair it.

Fixios,android,windows,macos,linux,blazor
A coalesced typing run now undoes completely. IMergeableCommand had existed since the spreadsheet shipped but nothing ever implemented it, so the path was never exercised: the undo stack kept only the first inverse of a merged run, which for typing meant a single character. Undoing a typed word rewound one letter. The retained inverse is now folded into a composite as the run grows.

ios,android,windows,macos,linux,blazor
Byte-identity is promised for an unedited deck. Opening a .pptx and saving it unchanged produces an identical file, asserted by hashing every ZIP entry. Once an edit happens the SDK’s only public flush re-serialises every part whose DOM was materialised — for a deck, every slide, layout, master, theme and notes part the reader had to walk. Those round-trip through the same object model so nothing is lost, and nothing is ever added to or removed from the package, but their bytes move. ⚠️ Not implemented, deliberately: soft line breaks, editing table cells or grouped shapes, adding or reordering slides, and rotation handles.

Featureios,android,windows,macos
Spell check in the document editor, using the platform’s own dictionary. Shiny.Maui.Controls.Office now registers the platform checker automatically — UITextChecker on iOS and Mac Catalyst, NSSpellChecker on macOS AppKit, SpellCheckerSession through Android’s text services, and the ISpellChecker COM API on Windows. Using the platform’s rather than shipping a dictionary is the whole point: it is the user’s dictionary, so words they have already taught their keyboard are known, and Add to dictionary writes back to it and is shared with every other app on the device. Misspellings get a red wavy underline; right-click — or long-press on touch — opens the corrections along with Ignore and Add to dictionary, and applying a correction is a single undo step. See Document Editor.

blazor
Blazor defaults to no checking, and takes one you supply. The browser spell-checks its own editable elements and exposes neither the results nor the suggestions to script — and a canvas is not an editable element in the first place — so there is nothing to call. Set SpellChecker on DocumentEditor/DocumentEditorView (or SpellCheckers.Default globally) with an ISpellChecker: a bundled dictionary, a call to your own service, or a domain word list. SpellCheckerBase handles the ignore list and language defaulting, leaving two methods to implement, and SpellingTokenizer is public so a custom checker gets the same exclusions — acronyms, camelCase, numbers, URLs, email addresses and paths, which every dictionary flags and no reader wants underlined.

ios,android,windows,macos,linux,blazor
Overridable everywhere, on either host. Registration goes through SpellCheckers.SetDefaultIfUnset, so an application that sets SpellCheckers.Default itself always wins and the platform checker is never even constructed. Per-control override is a bindable property on MAUI and a parameter on Blazor. Checking is per paragraph, cached on the paragraph’s text and limited to what is on screen — scrolling re-checks nothing already seen, editing re-checks one paragraph — and calls are debounced by 500 ms, because a platform checker is an interop call (a service round trip on Android) and a half-typed word is not a mistake. ⚠️ IsAvailable is false when there is no checker or no dictionary installed for the language; check it before telling a user spelling is on. Grammar is deliberately not reported — Android offers it, and ignoring it keeps all four platforms behaving the same.

Featureios,android,windows,macos,linux,blazor
A .docx editor, in two controls. DocumentEditor is the lone editing surface — canvas, caret, selection, typing, no chrome of any kind. DocumentEditorView is the same control wrapped in a formatting toolbar. Both sit on a shared DocumentEditorController carrying caret movement (including word and visual-line movement, which is not the same as paragraph movement in a wrapped document), selection, typing, Enter/Backspace paragraph splitting and merging, bold/italic/underline/strikethrough, font family and size, colour, alignment, and undo/redo. See Document Editor.

Featureios,android,windows,macos,linux,blazor
Edits are surgical on the runs, not on a rebuilt paragraph. A run is split only where an edit genuinely needs a boundary and is never re-created, because a run carries language, proofing state, revision marks and formatting the editor does not model — rebuilding one to change a single character throws all of that away. Formatting is expressed as a mutation of the run’s properties rather than a finished style, so turning on bold leaves the font, size, italics and colour exactly as they were. An unedited document still saves byte-identical, and editing text touches only word/document.xml.

ios,android,windows,macos,linux,blazor
The toolbar is deliberately asymmetric. MAUI has FontPickerButton and FontSizePickerButton but no toolbar; Blazor has ShinyToolbar but no font picker. Rather than invent a control on each side, each host composes what it already owns: MAUI builds a scrolling row of primitives around the two pickers, Blazor composes ShinyToolbar with plain selects. Same API, same behaviour, different internals.

ios,android,windows,macos,linux
MAUI typing works; physical keys need a hook. A hidden Entry gives the platform keyboard, IME and dictation somewhere to send text, so typing, tapping, selection and every toolbar command work. MAUI exposes no portable key-down event, so arrow keys and shortcuts route through HandleKey from a host’s own platform hook. Blazor is complete: input goes through beforeinput, which is the only event that reports IME composition, autocorrect, dictation and paste as ordinary insertions. ⚠️ Formatting with an empty selection updates the toolbar state only, not the document.

Fixblazor
Blazor now ships the fonts the documents actually ask for. SkiaSharp on WebAssembly has no access to system fonts at all — no fontconfig, no CoreText — and SKTypeface.FromFamilyName returns a wrong-but-non-null fallback rather than failing, so every .docx, .pptx and .xlsx rendered in a single monospace face and any glyph outside its coverage drew as a tofu box, with nothing logged anywhere. Shiny.Blazor.Controls.Office now bundles Carlito and Caladea (SIL OFL 1.1, eight faces, ~1 MB compressed), metric-compatible with Calibri and Cambria so line breaks land where the document intended. They load on the first render of any Office view, once per session, and are HTTP-cached. Add your own with OfficeFonts.Register(bytes). MAUI is unaffected and continues to use the platform’s fonts.

Fixblazor
The canvas was stuck at its intrinsic 600×300. The scoped stylesheet targeted the <canvas> that SKCanvasView renders — a child component’s DOM — so Blazor CSS isolation never applied the scope attribute and the rule was dead on arrival, silently. Fixed with ::deep. Relatedly, the viewport size could never change after the first paint: deriving the device-pixel ratio from the last known width made width algebraically equal to itself, so the resize branch was unreachable. The ratio is now read once from the browser.

Fixios,android,windows,macos,linux,blazor
PowerPoint bullets appear, and only where they belong. Bullets were drawn to the left of the text body’s inset — outside the shape, where the clip rect swallowed them without trace. They now reserve their advance inside the box. Separately, a shape that is not a placeholder was falling back to the master’s body list style and inheriting its bullet, so every plain text box grew a bullet it never asked for and the space reserved for it pushed centred text off centre; non-placeholder shapes now fall back to otherStyle.

Fixblazor
Two-way binding on SlideView no longer undoes itself. Assigning Mode during OnParametersSet raised Changed while Index still held its old value, pushing the stale index back to the parent and cancelling the navigation the parent had just requested — which made the previous/next buttons look dead. Parameter application is now guarded against writing back mid-update.

Featureios,android,windows,macos,linux,blazor
Word and PowerPoint viewers. DocumentView renders .docx and SlideView renders .pptx, both read-only, both in the existing Shiny.Maui.Controls.Office / Shiny.Blazor.Controls.Office packages and both painted by the same shared Skia pipeline as the spreadsheet. Word resolves the whole style chain — document defaults, the named style with its entire basedOn ancestry, then direct formatting — which is what makes documents built on custom styles render in the right font rather than the default one; on top of that, list numbering resolved from numbering.xml with running counters and compound labels like 2.a., tables with column spans, vertical merges and cell shading, inline images, hyperlinks, and an Outline() for a navigation pane. PowerPoint resolves shapes through slide → layout → master, which matters because a title placeholder normally carries text and nothing else — read only the slide and the whole deck stacks in the top-left corner. ⚠️ Same constraints as the spreadsheet: MAUI needs UseSkiaSharp(), Blazor is WASM-only, and the Blazor container needs an explicit height. See Document Viewer and Slide Viewer.

ios,android,windows,macos,linux,blazor
Word reflows; it does not paginate. Content is laid out as one continuous column at the control’s width — no page breaks, no headers, no footers. This is deliberate. Pagination needs widow/orphan control, footnote placement, floating-object collision and repeating table headers, and a partial implementation puts page boundaries in the wrong places — which reads as a rendering bug rather than a missing feature. WordDocument.Page reports the authored page size so the view can pick a sensible measure; it is not a promise of pages. Equally, PowerPoint scales rather than reflowing: slides are fixed-size artboards, so they are fitted and letterboxed and text keeps exactly the proportions it was authored at.

Featureios,android,windows,macos,linux,blazor
Theme colours arrive with their modifiers applied. “Accent 1, Lighter 40%” is not a colour in the file — it is accent1 with lumMod and lumOff underneath it, and the same goes for shade, tint and alpha. Ignoring them renders every themed shape at full saturation, which is the single most visible way a deck looks wrong. Around twenty preset geometries are drawn natively (rounded rect, ellipse, triangle, diamond, the four arrows, pentagon, hexagon, star, chevron, parallelogram, trapezoid, plus, can, cloud); anything else falls back to its bounding rectangle and says so through UnsupportedFeatureCollector, rather than silently drawing the wrong thing.

ios,android,windows,macos,linux,blazor
The viewers never write. Opening a document and saving it produces a byte-identical file, and both viewers report everything they could not draw — charts, SmartArt, footnotes, comments, headers and footers, custom geometry — so a document that contains something unrendered says so instead of quietly appearing to be missing content. Not implemented, deliberately: editing either format, pagination, floating-image text wrap, tab stops (a tab becomes four spaces), and charts/SmartArt/animation rendering.

Featureios,android,windows,macos,linux,blazor
Spreadsheet — open, render and edit real .xlsx workbooks. New Shiny.Maui.Controls.Office and Shiny.Blazor.Controls.Office, over a shared kernel (Shiny.Controls.Office.Shared) and a shared SkiaSharp renderer (Shiny.Controls.Office.Skia) — the two hosts drive the same controller and run the same paint routine, so they are one implementation rather than two kept in step. The grid virtualizes across all 1,048,576 rows, with frozen panes, merged cells, number formats, fonts, fills, alignment and theme colours with tint; editing covers cell values and formulas, range clear, column and row resize, range selection, and in-cell editing through a real Entry / <input> so the platform’s own keyboard and IME do the typing. ⚠️ Blazor is WebAssembly only — the grid repaints per keystroke and a Server round-trip per key is unusable, and SkiaSharp on WASM forces native relinking, so consumers need the wasm-tools workload. ⚠️ MAUI requires UseSkiaSharp() or the canvas never renders. ⚠️ The Blazor host fills its container, so that container needs an explicit height or it collapses to zero. See Spreadsheet.

Featureios,android,windows,macos,linux,blazor
Edits are surgical, and there is a test that proves it. The workbook is opened once and held, and changes go straight into the live XML DOM — nothing is ever rebuilt from a parsed model, so macros, tracked changes, custom XML, pivot caches, conditional formatting and charts survive a save because they are never read. Opening a workbook and saving it without an edit produces a byte-identical file; editing one cell rewrites only the sheet, shared strings, workbook and styles parts. The suite asserts this by hashing every ZIP entry before and after, and a corpus/ folder runs the same assertions against real documents. ⚠️ Getting here needed AutoSave = false: OpenXml otherwise re-serialises every part it has materialised, which meant simply opening a document was enough to rewrite it.

Featureios,android,windows,macos,linux,blazor
A formula engine, not just cached values. Roughly 80 functions across math, statistics, logic, text, lookup, date and information, with dependency-ordered incremental recalculation — an edit recomputes only what it affects — plus circular-reference detection that reports and leaves the cells at zero instead of recursing until the stack dies. Excel’s semantics are followed where they are surprising: operators coerce text to numbers while aggregates skip it, MOD takes the divisor’s sign, blank equals both 0 and "", IF never evaluates the branch it does not take, and serial 60 is the phantom 29 February 1900 that Excel believes in and the calendar does not. ⚠️ Read calculated cells with GetEffectiveValue / GetDisplayValue — Worksheet.GetValue returns the value cached in the file, which is stale the moment anything upstream changes.

Enhancementios,android,windows,macos,linux,blazor
Saving is atomic, and says what it could not handle. SaveAsAsync writes to a sibling temporary file and moves it into place, so an interrupted save never leaves a half-written document; the save also refreshes every formula’s cached result, because every reader other than Excel displays that value. Pass an UnsupportedFeatureCollector when opening to get a list of everything in the document the editor cannot render or edit, rather than discovering it by noticing something missing later.

ios,android,windows,macos,linux,blazor
Row and column insert/delete is not implemented, on purpose. It is the hardest edit in the format — references have to be rewritten across formulas, merged cells, conditional formatting, defined names, data validation, charts and tables — and a half-correct version silently corrupts documents. Also absent for now: changing merged cells, editing charts or pivot tables, multi-range selection, copy/paste, drag-to-fill, and physical-key navigation on MAUI (which has no portable key-down event — route keys through the controller from your own platform hook).

Featuremacos
The Office controls now render on the macOS AppKit head. SkiaSharp.Views.Maui multi-targets iOS, Mac Catalyst, Android and Windows but not net10.0-macos, so that head resolved its plain net10.0 build — where SKCanvasViewHandler.CreatePlatformView() throws NotImplementedException, which the macOS ShellHandler swallows. All five Office controls are an SKCanvasView, so all five came up blank with nothing in the log but “The method or operation is not implemented.” Shiny.Maui.Controls.Office now ships the missing platform view: an NSView handing out an SKSurface over a raw pixel buffer, blitted through a CGImage, with mouse, drag, right-click and wheel wired to SKTouchEventArgs. It renders CPU-side rather than through Metal on purpose — a document repaints on scroll and on keystrokes, not every frame, and a raster surface behaves identically to the one iOS and Android hand the painter, which is what keeps the rendering shared.

ios,android,windows,macos,linux,blazor
Registration moved to UseShinyOffice(). It calls UseSkiaSharp() itself and then, on AppKit only, replaces the stock canvas handler — so call it instead of UseSkiaSharp(). On every other head the two are equivalent and an app switching loses nothing. Nothing else changes: spell checking still installs itself through a module initializer. ⚠️ Two AppKit gaps are worked around rather than fixed, because they are the head’s: VisualElement.Focus() does not take there (verified against a plain, fully visible Entry), so the editors force first responder natively and suppress the focus ring AppKit would otherwise draw over the caret; and that head raises Completed on a focus change rather than only on Return, which was inserting a paragraph break on every click that moved the caret.

Fixios,android,windows,macos,linux
A typed word no longer arrives as its own prefixes. Each Office editor takes keystrokes through a hidden Entry that is cleared after every insert, so the text arriving is normally just the new characters. The macOS AppKit head does not apply that clear to the native field before the next keystroke reaches it, so the entry kept accumulating and every keystroke re-inserted everything typed so far — “hello” went in as hhehelhell. The editors now diff against what they last consumed rather than against OldTextValue, which the clear resets to empty even on the head that kept its text; where the clear does land the behaviour is unchanged.
Fixios,android,windows,macos,linux
A TableView cell without an icon no longer indents its title. The row is a three-column grid — icon, title, accessory — with a ColumnSpacing between them. Both outer columns are Auto and both are usually empty, so an icon-less cell (the common case) still paid the spacing: every title sat 12px further in than the section header above it, while the accessory kept the row padding’s 16. The gaps are now margins on the icon and the accessory instead, so a column that collapses contributes nothing.

Fixios,android,windows,macos,linux
The TextEntry floating label no longer starts out unpositioned. Where the label rests, where it floats to, and how far the field is pushed down all depend on two measured heights — the label’s own and the field’s — and the geometry only recomputed from those two SizeChanged events. On the first layout pass they arrive in either order, so whichever fired first bailed out and the second had already been raised: the label was left sitting at the grid origin, flush against the left edge and detached from the outline, until something forced a re-layout (resizing the window fixed it, which was the tell). The pass now retries on the next tick, a bounded number of times.

Fixios,maccatalyst
A bottom FloatingPanel no longer paints the home-indicator strip a different colour to the card above it. The panel extends past the overlay host into the bottom safe area and fills that gap with a block of its own colour so the two read as one surface. That block was coloured from the raw PanelBackgroundColor property, which is null unless a consumer sets one — while the card is coloured through Tint(), which uses PanelBackgroundColor when it is set and otherwise falls back to the SurfaceContainerLow theme token. So in the ordinary case of never setting the property, the strip was handed no colour at all and fell through to a system default: a visible band under DurationPicker, SignaturePad and every other panel that ships without one, and most obvious in dark mode. The fill now reads the resolved colour off the card rather than the override sitting behind it, which follows an explicit PanelBackgroundColor for free — that is exactly what Tint() assigns — and only falls back to transparent for a theme that defines no SurfaceContainerLow at all.

Featureios,android,windows,macos,linux,blazor
Databound columns can format themselves. Money, percentages, dates, file sizes, booleans and enums all used to mean writing a CellTemplate / DataGridTemplateColumn — and a template column gives up sorting, filtering and inline editing to get there, which is a steep price for “show it as currency”. New DisplayAs picks a preset (Currency, Percent, Number, Date, Time, DateTime, FileSize, Boolean, Enum, Text) with Decimals tuning the numeric ones, and the column keeps being a real databound column. Around it: NullText for a null or empty value, Prefix/Suffix for a real one, TrueText/FalseText for the boolean glyphs, Culture per column, and TextFormatter — raw value in, string out — for anything the presets do not cover. ⚠️ Percent uses .NET’s "P", which multiplies by 100: store 0.15 to render 15%. ⚠️ Enum prefers the member’s [Description] and otherwise splits its name on PascalCase, so InProgress reads In Progress. An explicit format string still wins over the preset. See Column formatting.

Featureios,android,windows,macos,linux,blazor
Column alignment, wrapping and conditional cell styling. Alignment defaults to Auto — quantities right, everything else left — with HeaderAlignment following it so a header sits over its own values; a numeric column that is really an identifier can say DisplayAs="Text" to stay left. Wrap plus MaxLines lets one column breathe while the rest stay on a line. CellStyle takes the row item and returns text colour, background and weight for that one cell — red negatives, an amber overdue cell — returning null to keep the themed default. ⚠️ CellStyle is evaluated when a row binds, which covers the virtualized list recycling a row onto a different item but not a property changing on an item already on screen. ⚠️ On Blazor its colours are CSS strings (a theme token works as well as a hex) and its CssClass is not scoped to the grid’s isolated stylesheet — declare that class in app CSS. See Conditional cell styling.

Fixios,android,windows,macos,linux
A formatted MAUI column is searchable and groups the way it reads. StringFormat was being used two incompatible ways at once: the cell went through a MAUI binding’s own StringFormat, which needs "{}{0:C0}", while quick-filter search, group headers and anything else calling the column’s text went through IFormattable.ToString, which needs "C0". Whichever dialect you wrote, one of the two was wrong — in practice a Salary column showed $45,000 and quick-search over it matched nothing, and grouping by it produced Salary: 45000. Every path now formats through one method, and it accepts both dialects, so existing StringFormat="{}{0:C0}" markup keeps working and finally agrees with the rest of the grid.

Featureios,android,windows,macos,linux
MAUI DataGrid columns can be bound. Columns are BindableObjects rather than elements, so they are not in the visual tree and inherited nothing — a {Binding} on a column silently resolved against no context at all. The grid now pushes its own BindingContext down to its columns, and TextFormatter, CellStyle and Culture are BindablePropertys, so a view model can supply them instead of code-behind. ⚠️ A {Binding} on a plain CLR property of a BindableObject fails at XAML compile with MAUIX2002, which reads like the property does not exist — that is why these three are bindable and, say, Comparer is not.

Enhancementblazor
PropertyColumn.Format is now StringFormat, for parity with the MAUI column of the same name. Format still works and is honoured whenever StringFormat is unset; it is marked [Obsolete] with a message pointing at the new name. Nothing breaks — rename at your leisure.

Featureios,android,windows,macos,linux,blazor
Column widths can be percentages. Blazor already took any CSS length on Width, percentages included, and now says so; MAUI gains WidthPercent (1-100), which wins over Width when set. Outside HorizontalScroll it resolves to a star of the same factor — a star factor is a percentage, because MAUI’s Grid divides the available width in exactly the ratio of the factors, so columns summing to 100 each get theirs. ⚠️ Under HorizontalScroll there is no available width to share (the columns are meant to overflow it), so a percentage resolves against the scroller’s measured width instead, and percentages summing past 100 are what make the grid scroll. ⚠️ Before the first layout there is no viewport to take a percentage of: the column shows DefaultColumnWidth for that one frame and re-resolves the moment the real width arrives, which it also does on every later resize. Percentages are the one width form that reads the same on both hosts, so prefer them when a layout has to be written once. See Column widths.

Featureios,android,windows,macos,linux
Drag-and-drop column reordering on MAUI. MAUI could only reorder columns through the ‹ › arrows on each header; it now has the same DragDropColumnReordering switch Blazor does, off by default, and a header can be picked up and dropped between two others with a marker showing the edge it will land on. The drop moves the column inside Columns itself and raises the new ColumnReordered event, which is what you persist to restore a user’s layout. AllowColumnReorder is untouched and independent — the arrows remain the accessible, no-drag path to the same thing, so a grid can offer either, both, or neither. The drag runs on a pan gesture, not DragGestureRecognizer: the platform recognizers are broken on Mac Catalyst and missing entirely from the AppKit and GTK4 hosts, and even where they work the event carries no pointer position, so there is no way to tell which side of a column the finger is on. ⚠️ Under HorizontalScroll this claims sideways gestures that start on a header — the enclosing scroller takes a horizontal gesture away from its children the moment it crosses the touch slop, so the drag has to hold it off from the raw touch-down — which means the grid is scrolled by dragging a row rather than the header. See Column ordering.

Fixblazor
Dragging a column one place to the right actually moves it. The drop always inserted before the target, so removing a column and re-inserting it in front of its own right-hand neighbour put it back exactly where it started — the header simply refused to move, with no indication why. A drop now lands after the target when moving right and before it when moving left, and a marker on that edge says which before the finger is lifted. A drag released outside a header clears the marker instead of leaving it painted on the grid. Also new: ColumnReordered (every visible column id, left to right, after the move — persist it) and ResetColumnOrder().

Featureios,android,windows,macos,linux,blazor
DataGrid columns resize between bounds you set. Columns could already be dragged (Blazor ColumnResizeMode, MAUI AllowColumnResize), and a column could already refuse with Resizable="false" — what was missing was any way to say how far. New MinWidth / MaxWidth per column, falling back to the grid’s new MinColumnWidth (48) / MaxColumnWidth (unbounded): Blazor takes CSS values (MinWidth="80px", also emitted as min-width/max-width so they bound the layout too), MAUI takes doubles where 0 means “fall back to the grid”. Without a floor a user could squeeze a column down to nothing and lose the header text with nothing on screen telling them how to get it back. ⚠️ The grid-level pair bounds the drag, not the layout — a deliberately narrow Width="40" icon column stays 40 wide even though dragging it stops at 48; only a column’s own bounds also constrain its declared width. ⚠️ A MaxWidth below the MinWidth loses to the floor, so a contradictory pair still leaves a usable column rather than a sliver. ⚠️ On Blazor a non-pixel bound (%, em) is emitted as CSS but cannot clamp the drag, which works in pixels and falls back to the grid default; on MAUI a star column outside HorizontalScroll stays a star and is not clamped at all, because MAUI’s Grid has no bounded star and clamping would silently turn it absolute. Nothing to adopt — a grid that sets none of this keeps its current widths and gains only the 48px floor on a drag. See Column resizing.

Fixblazor
The first drag on a column nudges it instead of teleporting it. A column with no declared Width has no width in C# at all — the table decided it — and the resize seeded its start width from a hardcoded 150, so the first pixel of movement snapped a 195px column to 150 and then dragged on from there. The start width is now measured from the rendered header before the drag applies anything, and a pointer move that beats that measurement home is ignored rather than applied to a start width of zero (which on Blazor Server snapped the column shut). A drag now also ends on pointercancel, and on the pointer leaving the grid entirely, so a release outside the table cannot leave a column stuck to the cursor.

Fixblazor
ColumnResizeMode.Container actually preserves the container. It was a synonym for Column — both simply widened the column and let the grid grow. It now moves the boundary: whatever one column gains, the next resizable column gives up, and the pair’s total never changes. A neighbour that will not shrink past its own minimum hands the refusal back, so the drag stops with it instead of drifting wider. ⚠️ What the neighbour gives up is capped at what the drag asked for — a neighbour already sitting outside its own bounds would otherwise be yanked into them on the first pixel of an unrelated drag and hand the dragged column that whole correction as a jump.

Featureblazor
ColumnResized and ResetColumnWidths(). The grid reports each finished drag as a column id and a final pixel width, which is what you persist to restore a user’s column layout on the next visit; ResetColumnWidths() drops every column back to its declared width. The whole grid also holds the col-resize cursor for the length of a drag and suppresses the text selection a header drag used to paint across every row it passed.

Featureblazor
Dock panels can be marked unclosable. AddDockPanel<T> takes a canClose argument, defaulting to true. Pass canClose: false for a panel its surface cannot function without — a file explorer’s folder tree, an editor’s document area — and the tab stops offering a close button. Closing one of those was a one-way door: nothing on screen brings a panel back unless the app has built its own reopen affordance, so the layout simply stayed broken until storage was cleared. ⚠️ The flag is enforced by the host, not just drawn: HidePanelAsync refuses a panel registered this way, so a stray call cannot strand a layout either. Existing registrations are unaffected — every panel stays closable unless it says otherwise. See Docking.

Fixios,android,windows,macos,linux,blazor
The screen glow is a glow again, not a stack of coloured rectangles. The inward falloff was built by drawing the colour pools several times over, each pass clipped a little further in than the last. A clip has a hard edge, so every pass left its own rectangle outline across the screen and the effect read as stacked translucent boxes rather than as light — worst on a phone, where a 110pt band down each side leaves little in between. Each pass now draws the same pools slightly tighter instead of clipping them, so the colour piles up at the edge and thins away inward with no outline anywhere; the pools are radial and centred on the edge, so their own falloff is the boundary and nothing is clipped at all. Two sizing bugs went with it: a pool was 2.4 x Thickness — 264pt on a 420pt-wide phone, wide enough to read as a flat wash rather than an edge — and is now 1.15 x, so the reach inward actually matches Thickness; and BlobCount was taken literally, leaving unlit gaps between pools on anything bigger than a phone. ⚠️ BlobCount is now a floor rather than the count — enough pools to rim the screen without gaps are always drawn, well over five on a large display. Layers keeps its meaning (how deep the edge is) but no longer clips, so raising it softens instead of adding another outline.

Fixblazor
…and on Blazor it was a rotating rectangle. The mask that cuts the rim band out of the middle of the screen was applied to the very element the spin animation rotates, and a CSS transform turns an element’s mask with it — so the band swung round as a rectangle, sweeping its own corners across the viewport, instead of staying pinned to the edge. The mask now lives on the static parent and only the colour plate turns; the plate is also a square wider than the viewport’s diagonal, so no edge of it can rotate into view at any aspect ratio. The band itself was a hard mask-composite: exclude cut, which read as a saturated picture frame, and is now a pair of unioned gradients that fade inward over Thickness and blend the corners.

Fixios,android,windows,macos,linux
The in-app quick entry popup fits the screen it opens on. QuickEntryOptions.Width is a desktop window width and was applied verbatim to the in-app overlay, with no clamp to the page it was being laid out inside: the default 720 against a 420pt phone centred the card at x=-150 and put almost all of it off-screen, leaving a sliver under the navigation bar. It is now clamped to the host’s width less a margin, and re-clamped once the host has been measured — Show usually runs before that, so the very first open was the one that overflowed. Rotation re-clamps for free. Nothing to adopt; a desktop-sized Width simply stops being wider than the phone.

Fixblazor
The quick entry scrim dims the page instead of blacking it out. The backdrop carried its transparency in a CSS var() fallback — var(--shiny-color-scrim, rgba(0,0,0,0.35)) — but that token is defined, and defined as an opaque #000000, the way every other control here uses it (each pairs it with its own opacity). A fallback only applies when the token is missing, so the scrim painted solid black and hid the page it was supposed to dim. The dim now comes from opacity, including the end of the fade-in keyframe, which was fading to 1.

Fixmacos
The tray icon is the size of a menu bar item. An NSImage built from raw bytes takes the source bitmap’s pixel size, and NSStatusItem draws it at exactly that — so an ordinary app icon painted hundreds of points tall and swamped the entire menu bar. The status item’s image is now scaled to the status bar’s own thickness, keeping its aspect ratio; the icons on the tray’s menu items were already being sized this way, so the two paths now agree. ⚠️ The Windows change that came with it is compile-verified only: the whole-display glow’s edge bands are now sized by a pool’s radius rather than by Thickness, because a band only Thickness deep slices each pool off while it is still fading and leaves a hard line down the screen.

Featureios,android,windows,macos,linux,blazor
Prompt tools — and a read-aloud one in the speech add-ons. PromptView grew LeadingTools (beside the orb) and TrailingTools (before the microphone and submit glyphs), the prompt-bar equivalent of TextEntry’s tool slots. On MAUI a PromptTool shares the IconTextTool base with TextEntryTool, so Text/Icon/ToolColor/Command all behave the same; on Blazor it is a plain object rather than a component, which is what lets a tool be built in a view model and handed over as a parameter — and is the only way the popup’s own prompt, configured through a service, can be given one. A tool that needs to read or drive the prompt implements IPromptAwareTool (MAUI Attach/Detach) or overrides OnAttached/OnDetached (Blazor, which also hands it the app’s IServiceProvider, so it resolves what it needs without the hosting page wiring it). ⚠️ Whatever a tool subscribes to in attach must come off in detach — detach runs both when the tool leaves the collection and when the whole collection is replaced, and the tool object outlives the prompt. The first tool is PromptTextToSpeechTool, in Shiny.Maui.Controls.SpeechAddins and Shiny.Blazor.Controls.SpeechAddins: it reads the answer aloud through Shiny.Speech, hides itself while there is nothing to read, turns into a stop button while speaking, and takes AutoSpeak, HideWhenEmpty, TextSelector, SpeechRate, Pitch, Volume, VoiceName and Culture. ⚠️ On Blazor it is WebAssembly-only — the synthesiser is the browser’s speechSynthesis, which a Blazor Server box does not have. ⚠️ On MAUI it needs AddSpeechServices() (or AddTextToSpeech()): every tool in the package resolves its engine from DI and no-ops when nothing is registered, so a forgotten registration reads as a button that does nothing. See Prompt tools.

Featuremacos
Shiny.Maui.Controls.SpeechAddins now targets macOS (AppKit). The package was iOS/Android/Mac Catalyst/Windows, so the net10.0-macos head could not reference it at all — every speech tool, not just the new read-aloud one, was simply unavailable on the desktop head Quick Entry was largely built for. Added net10.0-macos; Shiny.Speech already ships an AppKit implementation behind it. Service lookup moved from Application.Current.Handler.MauiContext to IPlatformApplication.Current.Services at the same time — the alternate app heads ship their own handler types, so the Application handler is not a MauiContext carrier there and the old lookup came back null on exactly the platform being added. ⚠️ Linux is still out, and deliberately: there is no plain net10.0 target because Shiny.Speech’s net10.0 assembly implements only the browser engine, so a GTK reference would compile and then do nothing.

Featureios,android,windows,macos,linux
PromptView.Response — the answer as plain text. MAUI’s prompt could only be given a View, which is fine for a MarkdownView or a ChatView and useless to anything that needs the words: a read-aloud tool has nothing to speak. Response renders plain text in a built-in label and matches the property Blazor already had, so the two hosts describe an answer the same way. ResponseContent still wins when both are set, Escape clears whichever is there, and the new ResponseChanged event is what a tool listens on for the answer arriving. Nothing to adopt — existing ResponseContent code is unaffected.

Featureios,android,windows,macos,linux,blazor
Quick Entry — an assistant-style prompt popup, on every platform. New in the core packages on both hosts: a prompt surface summoned over whatever the user is looking at, with IQuickEntryService covering Show/Hide/Toggle and the popup’s content. The default content is the new PromptView — animated orb, prompt, suggestion rows and an expanding response area — which does no AI itself: handle Submitted, set IsBusy, assign the response (a Label, a MarkdownView, a ChatView; on Blazor a string or any markup). Every part of it is replaceable: Icon/IconContent/ShowIcon/IconSize for the leading mark, Placeholder for the prompt, and DropdownContent for the whole expanding area — which sizes itself to its content unless you pin it with DropdownHeight, in which case it scrolls (what you want for a list that changes length as the user types and would otherwise make the popup jump under the pointer). It is an ordinary control, so it also renders inline on a normal page. Keyboard is read at the host and offered to the content first: ↑/↓ walk the suggestions, Enter submits or picks, and Escape unwinds one layer of state at a time — cancel, highlight, response, prompt — before it closes the popup; your own content joins in through IQuickEntryKeyHandler. See Quick Entry.

Featurewindows,macos,linux
…and on desktop it can be a real window over other applications. QuickEntryOptions.Presentation chooses between an in-app overlay and a borderless, always-on-top OS window that opens over other apps — the Claude Desktop / Spotlight / Copilot-key behaviour. It defaults to Auto: the native window where Shiny.Maui.Controls.Desktop is registered on Windows, macOS (AppKit) or Linux, and the overlay everywhere else, so a shared codebase configures this once with no platform checks at the call site. InApp and Desktop force it either way, and IQuickEntryService.ResolvedPresentation reports what you actually got. Add the window with .UseDesktopQuickEntry(), which is safe to call unconditionally. ⚠️ iOS, Android, Mac Catalyst and Blazor are in-app only — a phone has no second option and a browser cannot make an OS window, so Desktop there resolves to the overlay and logs why rather than failing to open. ⚠️ Custom popup content that changes size must implement IQuickEntryAutoSize: the content sits inside the very window whose size it determines, so its arranged height is the window’s rather than its own and Measure returns a size cached from that same constrained pass. The in-app overlay needs none of this, being laid out by the page.

Featureios,android,windows,macos,linux
Overlay grew placement and an edge glow. ContentAlignment (Start/Center/End, default Center) and ContentMargin move an overlay’s content off-centre — a prompt bar near the top rather than a dialog in the middle — and ShowEdgeGlow + GlowOptions rim the page with the animated Siri-style colour wash for as long as the overlay is up, behind the content, in front of the backdrop and click-through. Quick Entry’s in-app presentation is built on exactly these rather than a scrim of its own, which is why a popup opened over a floating panel dims the page once instead of twice. A DataTemplate that returns the same view instance each time is now supported too, so an overlay can host one long-lived view rather than rebuilding it on every show. Existing overlays are unaffected: the defaults are the old behaviour.

Featurewindows,macos,linux
Global hotkeys. IGlobalHotKeyService comes with .UseDesktopQuickEntry() and is worth having on its own: Register("Ctrl+Shift+K", action) returns an IDisposable to release the key, and the callback is marshalled to the UI thread for you. ⚠️ Register returns null when the combination cannot be claimed — unsupported session, unparseable string, or another application already owns it — and that is the documented outcome rather than an exception, so handle it. At least one modifier is required; a bare key would hijack it system-wide. Backed by RegisterHotKey on Windows, Carbon RegisterEventHotKey on macOS (which, unlike an NSEvent global monitor, needs no Accessibility permission prompt), XGrabKey on X11 — grabbed with every lock-modifier combination so Caps Lock does not silently break it — and the org.freedesktop.portal.GlobalShortcuts portal on Wayland. Desktop only: a system-wide key grab does not exist on mobile or in a browser.

Featureios,android,windows,macos,linux,blazor
Screen glow — the Siri border effect. An animated colour wash around the edge, click-through and always-on-top, on the same service as the popup rather than one of its own: the two are almost always used together, and splitting them meant an app wiring up an assistant had to resolve, configure and keep two objects in step for one visible behaviour. ShowGlow/HideGlow/PulseGlowAsync work with no popup involved, so it doubles as a listening or recording indicator. QuickEntryOptions.ScreenGlow wires it to the popup — WhileBusy is the Siri-like one, lighting only while your content reports itself working (PromptView out of the box, or IQuickEntryBusyState on your own content). ⚠️ It rims the display in desktop presentation and the page in-app — the same thing on a phone, and not the same thing on a desktop with your app in a window — and availability differs from the popup, so check IsGlowSupported separately. On Windows a WinUI 3 window has no per-pixel alpha, so the desktop glow is rendered with GDI+ into four layered Win32 windows, one per screen edge, which is why it has square corners there.

Fixlinux
Wayland limits are stated rather than papered over. A Wayland client cannot position its own toplevel, raise itself above other windows, or grab the keyboard, and GTK 4 dropped gtk_window_move and set_keep_above to match. Under X11 the desktop quick entry window behaves exactly as it does on Windows and macOS; under Wayland it is still undecorated and transparent, but the compositor places it and it is an ordinary window in the stack, the whole-display glow is unavailable (the in-app one is used instead), and hotkeys go through the desktop portal — where binding shows the user a confirmation dialog (so the hotkey starts working asynchronously after startup) and the trigger you ask for is only a preference the compositor may override. Never present a configured accelerator as fact on Wayland.

Featureios,android
The markdown editor’s toolbar now rides the soft keyboard. On a phone the formatting toolbar sits above the editor, which is precisely where the keyboard covers it the moment you start typing — so every enabled ToolbarItem is now also rendered as an icon on a KeyboardAccessoryView docked to the top edge of the keyboard: a horizontally scrolling row, grouped with the same separators as the toolbar, with a Done pinned beside it. The Done is not decoration — this is a multi-line editor, so the return key inserts a newline and nothing else puts the keyboard away. On by default (ShowToolbarInKeyboard); set Accessory to supply a bar of your own. Change ToolbarItems and the keyboard bar follows it, so the two cannot drift apart. Windows, macOS, Linux and Blazor have no soft keyboard to decorate — the properties compile and do nothing there. See Toolbar on the keyboard. ⚠️ Shiny.Maui.Controls.Markdown now depends on Shiny.Maui.Controls, which is where the accessory bar and its iOS/Android plumbing live.

Featureios,android
The keyboard accessory bar serves multi-line inputs. It was bound to Entry — a UITextField on iOS — so an Editor could not carry one at all. It now binds to any InputView, which is what let the markdown editor above have one. KeyboardAccessoryItems nested inside a BarContent layout are also wired to the focused field now, however deep they sit; before, only items in the flat Items list were, so a Done placed inside a custom bar layout silently did nothing. IconTextTool (and so every accessory item and text-entry tool) gained FontAttributes, for glyph labels like a bold B that mean their own styling.

Fixios,macos
A burned-in recording that never gets to finish is no longer lost. An AVAssetWriter only writes the moov atom when FinishWriting completes, so a recording ended by a crash, a force-quit or a battery pull left a file nothing will decode — however many minutes of footage went into it. The burn-in recorder now sets MovieFragmentInterval to 10 seconds before it starts writing, so movie fragments land as capture proceeds and an abandoned file plays up to the last one. Ten seconds because that is exactly what AVCaptureMovieFileOutput already defaults to on the raw path: this was the same class of surprise the explicit video bitrate closed — attaching an overlay silently swapped a crash-recoverable recording for an all-or-nothing one, for reasons nothing in the API hinted at, and the two paths now behave the same. ⚠️ Android has no counterpart. CameraX muxes through MediaMuxer, which cannot write fragmented MP4 and exposes no interval to set, so an unfinished recording there is still lost — an app where a hard kill mid-capture is a real risk (dash cams, body cams, long unattended captures) should record in segments on Android rather than one long file. Nothing to adopt on Apple; existing recordings simply survive more.

Featureios,macos
The camera can stop asking for a pixel format nothing wanted. Apple’s sensor delivers biplanar YCbCr; asking an AVCaptureVideoDataOutput for 32BGRA — which is what every release until now did, unconditionally — makes the capture pipeline colour-convert every frame for as long as the preview is up, idle or not, recording or not, analyzer or not, and the hardware encoder then converts it back to YUV to write the file. A burned-in recording paid that full-frame conversion twice per frame where the raw AVCaptureMovieFileOutput path pays none. CameraView.CaptureFormat = CameraCaptureFormat.Yuv420 asks for the native format instead, and a device that does not list it silently keeps BGRA rather than failing. ⚠️ It is opt-in because BGRA is what a CPU can draw on. An overlay composited through ICompositedVideoOverlayRenderer never touches the CPU and is unaffected — that pairing is the point — but an IDrawEffect, or an overlay falling back to DrawOverlay, needs a CGBitmapContext, which cannot be built over a biplanar buffer: the recorder converts to a scratch surface and back on every frame, which is a worse trade than never leaving BGRA. CameraFrame.Format reports what the frame actually holds, and luminance reads get cheaper on the new format — NV12’s first plane already is luminance, so SampleLuminance reads one byte per sample instead of four plus a Rec.601 multiply-add, while ToCGImage() becomes a GPU conversion paid only on the frames an analyzer takes. Video-range luma is expanded to full range on read, so an ambient-light consumer sees the same numbers either way.

Featureios,macos
Camera frames are only delivered while something consumes them. The sample-buffer delegate was attached when the session started and stayed attached, so a plain preview with no analyzer, no effects and no recording still ran the whole frame path. It is now attached when — and only while — one of those three exists. This sits a layer below IFrameAnalyzer.WantsFrame(), which decides whether an already delivered frame is worth wrapping: by the time that runs, AVFoundation has already produced, converted and handed over the buffer. Nothing to adopt; a preview-only camera simply costs nothing on the video queue now.

Fixios,macos
The GPU layer compositor no longer churns Core Image wrappers per frame. Each composited layer allocated a CIImage around the capture buffer, a transformed copy of the layer image, and the filter’s output image, and disposed none of them — on a four-layer HUD at 30fps that is 360 undisposed native wrappers a second, allocated on the capture queue, which is the one thread that cannot afford to wait for a finalizer. The background wrapper is now built once per frame and the per-layer transients are scoped.

Featureios,android,macos
A burn-in video overlay can hand over pre-rendered images instead of being drawn into every frame. Implement ICompositedVideoOverlayRenderer beside IVideoOverlayRenderer and return VideoOverlayLayers from GetLayers — each an IImage, the rect it lands on, and a Version that moves when the contents do. A HUD, watermark or telemetry panel usually repaints once or twice a second while being drawn onto thirty frames, and this is what lets the platform stop redrawing it. On Apple the recorder then composites through Core Image on the GPU and never maps the capture buffer into CPU memory at all — that map is CVPixelBuffer.Lock with read-write flags on every frame, a fixed cost that does not shrink with how little of the frame the overlay covers, so an overlay occupying a sixth of the picture was paying for all of it. On Android the recording surface is already hardware-composited, so it changes the draw calls but not the cost. ⚠️ null and an empty list are different answers: null falls back to DrawOverlay for that frame, an empty list means there is genuinely nothing to draw — swapping them produces a recording with no overlay and no error. ⚠️ A Version that never moves burns the first frame’s image into the whole recording, since the platform caches its own representation against it. Entirely optional in both directions, so adopting it can cost speed but never correctness; a chain containing any IDrawEffect keeps the ordinary draw path.

Featureblazor
ShinyToolbar items can open menus. Give a ToolbarItem a Children list and it stops raising ItemClicked and becomes a menu button instead — a caret appears (DropdownIcon), clicking it opens a dropdown, and children with children of their own fly out as submenus, nested as deep as you like. IsSeparator draws a divider between groups, a menu button that collapses into the overflow keeps its children as a submenu row there, and ItemClicked still reports the leaf that was invoked (identify it with Tag — a label is not unique across submenus). Menus close on an outside click, on Escape, or after a selection. See Dropdown buttons & submenus.

Fixblazor
A ShinyToolbar dropdown is no longer clipped by whatever the bar lives in. The overflow menu was an ordinary absolutely-positioned panel, so a toolbar inside a card, an AppLayoutPanel or any overflow: hidden scroller — which is where an app’s toolbar usually is — had its own menu cut off at that ancestor’s edge, and any higher z-index on the page painted over it. Panels are now drawn in the browser’s top layer through the popover API, placed against the button they hang off, flipped to the other side or above when they would run past the viewport, and re-placed when the surrounding scroller moves. Browsers without the popover API fall back to the old positioning.

Fixblazor
ShinyToolbar Items and EndContent now coexist. Supplying EndContent used to suppress Items entirely and switch the overflow behaviour off with them, so trailing actions built as markup simply overflowed the bar and wrapped instead of collapsing. The two now sit side by side in the trailing region: Items collapse into the dropdown as the bar narrows and EndContent stays pinned, which is what you want for a status pill or an avatar that has to remain visible. Bars that only ever passed one or the other are unaffected.

Fixios,android,windows,macos,linux
A hidden TableSection no longer leaves its section separators behind. The separator between sections was placed by counting every section, including the hidden ones — which render as a zero-height placeholder. So a section hidden between two visible ones drew two rules stacked together, and hiding the trailing sections left a rule under the last visible one with nothing below it; both read as a rendering fault rather than as a section being hidden. Separators are now counted across the visible sections only, so binding TableSection.IsVisible to a feature flag or a mode switch does what it looks like it does. (Blazor was never affected — a hidden section renders nothing there.)

Featureios,android,windows,macos,linux,blazor
DataGrid detail (“breakdown”) rows — set a RowDetailTemplate and the grid grows a caret column at the leading edge; expanding a row opens a full-width row beneath it, bound to that row’s item, that can host any controls you like. ExpandMode="Single" keeps one open at a time, IsRowExpandable vetoes rows with nothing to show, ExpandOnRowTap/ExpandOnRowClick opts a whole-row tap in, and ExpandRow/CollapseRow/ToggleRow/ExpandAll/CollapseAll drive it from code alongside RowExpanded/RowCollapsed events (Blazor also exposes a bindable ExpandedItems). Expansion is keyed on the data item, so it survives the rebuild sorting, filtering or paging causes, and the breakdown stays pinned to the leading edge while the columns scroll sideways. See Detail (breakdown) rows.

Featureios,android,windows,macos,linux,blazor
Async detail rows + a grid IsBusy. RowDetailLoader fetches a breakdown the first time its row is opened: that row’s caret turns into a spinner — the caret is the button, so that is where the progress belongs — and the detail row shows RowDetailLoadingTemplate (a spinner by default) until the load lands. RowDetailTemplate is not built until then, so it can assume its data arrived. The loader deliberately returns no value: fill an observable property on the item (or a lookup keyed by it) and let the template bind to it, which keeps the template’s context the row’s item rather than a wrapper. Each item loads once; InvalidateRowDetail(item) refetches (immediately if that row is open) and a throw collapses the row and raises RowDetailLoadFailed. The tree’s ChildrenLoader now shows the same caret spinner instead of a static glyph. New IsBusy — a read-only bindable on MAUI, a property plus IsBusyChanged on Blazor, with IsRowBusy(item) per row — is true while any children or detail load is in flight; it is distinct from IsLoading/Loading, which you set yourself to cover the grid while its data loads. See Loading a breakdown on demand.

Featureios,android,windows,macos,linux,blazor
TreeDataGrid — the DataGrid in hierarchy mode. Hand the grid a ChildrenSelector and rows nest, with the indent and expand caret carried inline by the first column; ChildrenLoader fetches a level the first time it is expanded (a ◌ glyph shows meanwhile) and caches it, HasChildrenSelector lets leaves render caret-free before anything loads, and ChildrenLoadFailed reports a failed fetch. TreeDataGrid is DataGrid — same type, clearer name — so columns, sorting, filtering, frozen columns, selection, editing and aggregates all work unchanged. ⚠️ ChildrenSelector gets first refusal: the loader only runs for items it returns null for, which is what lets one tree mix in-memory branches with fetched ones (an empty collection means “leaf”, not “ask the loader”). Sorting and filtering apply per level so children stay under their parent, and a row whose descendant matches a filter is kept — otherwise the match would sit under a pruned parent. Paging pages the roots; tree mode and Groupable are mutually exclusive (grouping wins). See Tree mode.

Featureios,android,windows,macos,linux,blazor
DataGrid frozen columns — pin a contiguous run of columns to either edge so they stay put while the rest scrolls sideways: per column with Frozen="Start"/"End", or by count on the grid with FrozenColumns/FrozenEndColumns (which also pins the multi-select checkbox column). Pinned cells paint an opaque background and repeat the row’s own stripe/selection/hover state, so content passes underneath them instead of showing through. ⚠️ MAUI needs the new HorizontalScroll="True" — without sideways scrolling there is nothing to pin against — and in that mode star widths cannot survive the scroller’s unbounded measure, so each resolves to DefaultColumnWidth (150 by default) × its star factor. Blazor needs no flag; give pinned columns a px Width and the offsets are exact on the first paint. See Frozen header & frozen columns.

Fixblazor
FixedHeader actually sticks now. Height was capping the grid’s outer element while overflow: auto lived on the scroller inside it, so nothing ever scrolled and position: sticky had nothing to stick against. The height now lands on the scroller, the filter row pins below the header instead of overlapping it, and the table switches to border-collapse: separate while anything is pinned — collapsed borders vanish from sticky cells.

Fixios,android,windows,macos,linux
MAUI DataGrid rows now rebuild when the column layout changes. Changing the columns, their widths, Dense, Bordered or RowHeight at runtime re-created the item template, but CollectionView handed the cells it had already built straight back and only re-bound them — so the header re-laid out and the rows kept their old column layout. The grid now swaps the CollectionView when the row shape changes (and only then, so ordinary data reloads still keep their cells).

Featureios,android,windows,macos
IFrameAnalyzer.WantsFrame() — an analyzer can now decline a frame before the platform builds one. The pipeline asks on the capture thread, ahead of wrapping the native buffer, so an analyzer with a cadence declares it instead of paying for every delivered frame and returning early. ⚠️ This is where a rate limit belongs — an early return from AnalyzeAsync happens after the frame exists, which on Apple meant a full-frame pixel copy (8.3 MB at 1080p, on the Large Object Heap): a 5-passes-a-second analyzer was costing 30 copies a second and discarding 25. Ships with a default of true, so existing analyzers are unaffected. The pipeline also skips frames while a pass is still in flight, which it previously built and then dropped.

Fixios,macos
The Apple camera frame no longer copies every frame. AppleCameraFrame held a managed BGRA snapshot taken eagerly in its constructor — at 1080p30 that is ~250 MB/s of memcpy and thirty Large Object Heap allocations a second, whether or not anything used them. It now holds the capture’s CMSampleBuffer open and reads it in place, the way AndroidCameraFrame has always held its ImageProxy; Bgra materializes only if something asks for it, and ToCGImage (which every in-tree Apple analyzer uses) builds straight off the buffer instead of off a copy of it. ⚠️ It falls back to copying while a burn-in overlay recorder is attached, because that composites into the very buffer an analyzer would be reading.

Featureios,android,windows,macos
CameraFrame.SampleLuminance(destination, columns, rows) — an evenly spaced grid of luminance samples for consumers that want a statistic (ambient light, exposure) rather than an image. GetLuminance() materializes the whole plane, so reading a 32×32 grid out of it paid two million reads and a 2 MB allocation for a thousand values; the Apple and Android frames now read only the sampled pixels off the native buffer.

Featureios,android,windows,macos,linux,blazor
MediaElement.VideoSize — the pixel size of the video track is now surfaced on the control, so a layout can size itself to whatever it is playing instead of guessing (a portrait clip in a fixed landscape box is fitted to the box’s height and drawn as a narrow sliver). It is a read-only bindable, paired with a new VideoSizeChanged event; Blazor exposes the same thing as VideoWidth/VideoHeight with OnVideoSizeChanged. ⚠️ Read it through the binding or the event, not once inside MediaOpened — Android reports the size from a separate player callback that routinely lands after the media is open, and an adaptive stream changes it mid-playback. See Sizing to the video.

Featureios,android,windows,macos,linux
IMediaPlayerBackend.VideoSizeChanged — custom playback backends can report a size that arrives (or changes) after the source opened. The member ships with a no-op default implementation, so a backend written against 1.0 still compiles unchanged; it simply never reports a change.

Featureios,android
CameraView.Orientation — the camera now follows the device as it rotates, across the live preview, captured stills, recorded video and the frames handed to a FrameAnalyzer. Defaults to CameraOrientation.Device; pin it to Portrait, PortraitUpsideDown, LandscapeTopLeft or LandscapeTopRight when several recordings have to agree with one another (segmented continuous capture). A change is deferred while a recording is in progress, because transposing the pixel buffer under an encoder configured from its first frame corrupts the file rather than rotating it. See Orientation.

BREAKINGios,android
Capture output is no longer always portrait. This is the fix behind the feature above, and it changes the files an existing app produces. On Apple every AVCaptureConnection defaults to portrait and only the frame-delivery one was ever oriented — so a landscape-held device previewed sideways, recorded sideways through the native movie-file path, and wrote portrait EXIF onto stills. On Android no target rotation was set at all, so it silently inherited whatever the display rotation happened to be at the moment the use cases were built: launch the app already in landscape and every recording came out rotated, with nothing in the code saying so. Both now honour Orientation. An app that relied on always-portrait output must set Orientation = CameraOrientation.Portrait explicitly.

Fixandroid
The shared CameraX ViewPort (bound when frame analysis and recording run together) now takes the rotation the use cases were actually given, rather than reading it back off the Preview use case’s default.

Initial Release