Controls Releases
2.1.0 - TBD
Section titled “2.1.0 - TBD”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.
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.
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.
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.
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.
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.
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.
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.
#c4c6d0 instead of the theme’s outline colour (#BBC7DF). See Border Animations.
UseShinyFloorPlan() now registers that handler itself, and the package builds for net10.0-macos. See Floor Plan.
AddSpeechServices() instead of doing nothing silently. See Speech Add-ins.
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.
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.
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.
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.
2.0.0 - October 22, 2026
Section titled “2.0.0 - October 22, 2026”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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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, asCoverFlowViewalready 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.
FileSystemOfficeDocumentStorecloses 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.
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.
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.
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.
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.
- ShinyToolbar: when the bar is too narrow, the title/start region gives up space first and a long title ends in an ellipsis, so
EndContentand the actions are never pushed off-screen. See Toolbar. - BarcodeView / QRCodeView: the code is capped at
max-width: 100%and, with noCssHeight, keeps its aspect ratio (height: auto), so a 400px barcode scales down inside a phone instead of overflowing. An explicitCssWidth/CssHeightstill 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:
FitOnLoadnow 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.
- 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. WithOutlinedor 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/FontSizePickerButtondropdowns 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.
- 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
ScrollViewlaid 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’sAutomationIdis 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 fixedHeightRequestinside aScrollView. 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 defaultShiftslid the detail 64pt off the right edge. The Flyout and FAB pages no longer scroll by a few pixels for nothing.
- 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.
./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).
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.
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.
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.
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.
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.
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.
1.8.0 - October 15, 2026
Section titled “1.8.0 - October 15, 2026”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.
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.
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.
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.
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.
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.
1.7.0 - October 14, 2026
Section titled “1.7.0 - October 14, 2026”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.
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”.
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.
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.
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.
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.
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.
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.
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.
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().
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.
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.
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.
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.
SetCustomToaster<T>() now registers your toaster. It registered the built-in Toaster whatever T was, so a custom IToaster was never used.
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.
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.
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.
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.
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.
Shiny.Blazor.Controls.
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.
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.
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.
Theme redraws the diagram. It only re-rendered when Diagram changed. See Mermaid Diagrams.
ProgressLineHost passed the configuration strings without @, so the literal text config.BarColor reached the CSS. See ProgressLine.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
1.6.0 - October 9, 2026
Section titled “1.6.0 - October 9, 2026”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.
Anglesets the direction: 0 scrolls left, 90 up, and any other value travels diagonally.Reverse,DurationorSpeed, andGapcontrol 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 theChildContent.
See Marquee.
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.
<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.
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.
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(defaulttrue) bakes the EXIF orientation into the pixels. Withfalse, the orientation tag is always kept.PreserveMetadata(defaulttrue, like MAUI) keeps EXIF, GPS and TIFF fields, with orientation, dimensions and the thumbnail corrected. Set it tofalseto 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.
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.
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.
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.
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.
1.5.0 - September 30, 2026
Section titled “1.5.0 - September 30, 2026”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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Layouts, SetLayout, NewSlide(layout), SetNotes).
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.
1.4.1 - September 18, 2026
Section titled “1.4.1 - September 18, 2026”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.
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.
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.
1.4.0 - September 16, 2026
Section titled “1.4.0 - September 16, 2026”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.
CheckBoxColor, MyTextColor and OtherTextColor still win.
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.
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.
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.
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.
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.
OnSurfaceVariant token live through a shared themed icon view, which the Office ribbons use too.
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.
@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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
<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.
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.
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.
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.
corner-full. Terminal is the extreme case and it is 194 lines, most of it palette. Nothing about linking a pack changed.
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.
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.
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.
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.
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.
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.
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.
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.
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.
: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.
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.
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.
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.
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.
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.
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.
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.
<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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
GoTo, or a binding on SelectedIndex — while a reselect correctly leaves the menu alone, since the page underneath it has not changed.
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.
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.
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.
ShinyTabBar: HasOverflow, OverflowItems, OpenOverflow(), SelectOverflowItem(item), and tab-more as the cell’s AutomationId.
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.
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.
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.
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°.
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.
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.
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.
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.
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.
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.
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.
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.
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.
ParentId, the shape rows arrive from a database in, or nested Children, the shape a view model is usually already in.
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.
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.
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.
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.
<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.
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.
1.4.0 - September 7, 2026
Section titled “1.4.0 - September 7, 2026”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.
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.
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.
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.
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.
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.
1.3.0 - September 7, 2026
Section titled “1.3.0 - September 7, 2026”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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
: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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
=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.
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.
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.
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.
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.
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.
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.
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.
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.
DocumentEditorController.ChromeText is new, and reads the current line back so the prompt opens on what is already there. See Document Editor.
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.
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.
NSCameraUsageDescription and NSPhotoLibraryUsageDescription.
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.
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.
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.
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.
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.
‹ › 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.
.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.
.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.
ToolbarTemplate still replaces the whole bar. See Image Editor.
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.
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.
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.
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.
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.
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.
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.
1.2.0 - August 28, 2026
Section titled “1.2.0 - August 28, 2026”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.
<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.
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.
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.
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.
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.
--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.
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.
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.
RequestedTheme, so a light editor could sit under a near-black formatting bar after a theme change.
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.
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.
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.
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).
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.
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.
▾ 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
ReleaseFilesAfterHandling = false keeps them for a read you do later, and ReleaseAsync(files) lets go when you are done.
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.
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.
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.
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.
<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.
SetColumnWidthCommand (undoable like any other edit), and the toolbar’s fit-to-contents button and SetColumnsHidden write to the file as well.
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.
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.
currentColor (or the theme’s on-surface token on MAUI) is what lets one icon follow the toolbar, the theme and the disabled state.
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.
A-over-a-bar artwork and tints only the bar with the colour it would apply.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
w:drawing now contributes U+FFFC to the offset space, and Split counts runs the same way the reader does.
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.
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.
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.
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.
.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
.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.
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.
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.
.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.
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.
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.
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.
.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.
word/document.xml.
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.
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.
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.
<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.
otherStyle.
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.
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.
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.
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.
.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
1.1.0 - August 23, 2026
Section titled “1.1.0 - August 23, 2026”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.
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.
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.
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.
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.
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.
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.
ColumnReordered (every visible column id, left to right, after the move — persist it) and ResetColumnOrder().
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.)
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
1.0 - August 14, 2026
Section titled “1.0 - August 14, 2026”Initial Release


