Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

Controls Releases

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

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

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

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

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

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

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

Featureios,android,windows,macos,linux,blazor
Find, on all three Office controls. Home ▸ Find carries a box, a 3/12 readout and a previous/next pair on the Word, PowerPoint and Excel toolbars — one OfficeFindBar per host over one IFindController, which all three finders implement, so the bar has no idea whether “the next one” is a paragraph below the fold, a shape on slide nine or a cell three sheets over. Typing searches as you type and steps onto the first hit at or after the caret, not the top of the content — a find that always restarted at the beginning takes the user away from what they were reading — and the arrows wrap at either end, because a “next” that goes quiet at the last hit looks identical to one that has finished the document. A hit is selected rather than merely scrolled to: everything a person does after finding a word operates on the word. MatchCase and WholeWord are on the controller rather than adding two toggles to the bar, and whole-word uses the same rule double-click selection does, so don does not match don't. Finding changes nothing, so it stays live in a read-only editor. See Find in Office Documents.

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

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

Featureios,android,windows,macos,linux,blazor
The motion icon set goes from 42 icons to 111. Sixty-nine new ones, each with motion authored for it rather than a preset applied to it: a folder tab that lifts off its crease, a page that turns by squashing about the spine, three raindrops that fall, vanish and reappear above the cloud, a compass needle that settles in progressively smaller swings, a credit card that flips through a horizontal scale of zero. The additions fill the gaps the original set had — a complete set of arrows and chevrons, the rest of the transport bar (stop, record, skip-back, skip-forward, shuffle, repeat, mute), files and folders, weather, and the round status glyphs (check-circle, x-circle, help, hourglass) — and they are grouped in the docs as Actions, Navigation, Objects, Media, Files, Weather and Indicators. The names stay one flat, case-insensitive namespace, so nothing about lookup or MotionIconLibrary.Names changes and no existing icon was renamed or redrawn. Directional icons are deliberately matched sets: every arrow travels the way it points and pulls its shaft in behind the head, and every chevron bounces once in its own direction, so swapping arrow-right for arrow-left in a right-to-left layout gets the mirrored motion for free. See Motion Icons and the icon set.

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

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

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

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

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

Featureios,android,windows,macos,linux,blazor
The Spreadsheet, Document Editor and Slide Editor toolbars are Ribbons. All three were a single scrolling strip of two dozen icons separated by anonymous hairlines; they are titled groups now — Font / Alignment / Number / Editing on the spreadsheet, Font / Paragraph / Insert / Page in Word, Slide / Font / Paragraph / Insert in PowerPoint — with undo and redo in the quick access row where they never move. The tab strip is off by default where there is a single tab to show: these are bars a host drops above a surface, not an application’s whole chrome, and a strip carrying one “Home” is noise. The spreadsheet has two tabs and shows it. See Spreadsheet, Document Editor and Slide Editor.

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

Featureios,android,windows,macos,linux
Ribbon.SimplifyBelowWidth switches the bar to the dense one-row layout on its own. Group collapsing is the wrong answer at phone width: it folds groups into dropdowns worst-first, which is right when a window is a little too narrow, but on a phone there is room for no group at all — every command ended up behind a dropdown, worse than the strip it replaced. Three controls wanted the same rule, so it belongs to the ribbon rather than being copied into each host. A collapse the user asked for is never overridden.

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

Featureios,android,windows,macos,linux,blazor
Page orientation. Layout ▸ Page carries Portrait and Landscape — two toggles rather than one, because a page is one of two things rather than on or off. Turning the paper swaps the dimensions and writes w:orient, which are two halves of the same change: swapping without the attribute gives a page the right shape that Word still calls portrait, so Word’s own control shows the wrong state and the next change flips it the wrong way; writing the attribute without swapping gives a section claiming landscape on portrait paper, which Word obeys by re-swapping on open. Margins are left alone, as Word leaves them. Undoable, and it survives a save and reopen. See Document Editor.

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

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

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

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

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

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

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

Featureios,android,windows,macos,linux,blazor
Shapes are a ribbon tab in both editors, not a dropdown. Twenty shapes behind one button is a panel large enough to cover the document it is about to draw on, and it has to be dismissed before the result can be seen. They are a Shapes tab now — Rectangles, Basic, Arrows — and every button is drawn as the shape it inserts, using the same polygon, star and arrow maths the painter uses to lay that shape into the document. Hand-drawn icons drift from what actually gets inserted the first time either side is adjusted. The gallery, its grouping and its names are shared by both editors and both hosts, replacing a MAUI array and a Blazor gallery that were kept in step by a comment saying they had to match. See Document Editor and Slide Editor.

Featureios,android,windows,macos,linux,blazor
Page margins are on the ribbon. Normal, Narrow, Moderate and Wide are four buttons in Layout ▸ Margins rather than one button that opens a sheet of four. Four is few enough to show, and the whole point of a ribbon is that the choices are on it.

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

Featureios,android,windows,macos,linux,blazor
The document editor’s toolbar has tabs — two of them. Home is what you do to the text under the caret: Font, Paragraph and Proofing. Layout is what you do to the page it sits on: Page Setup, Insert and Zoom. It was tried with four, and Insert, Layout and Review each ended up holding a single group — a click to reach a bar with one button on it. Proofing rides on Home rather than a Review tab of its own, because spelling is something you do while writing rather than a separate pass. See Document Editor.

Featureios,android,windows,macos,linux,blazor
Zoom, and a fit-width that makes a page readable on a phone. Zoom was a property with no way for a user to reach it. Now: pinch on touch, ctrl-wheel on the desktop — which is not a shortcut anyone had to learn, but what a trackpad pinch is delivered as in every browser — and a Zoom group on the Layout tab stepping 50 / 75 / 100 / 125 / 150 / 200 / 300%. Fit width sets the zoom so the page exactly spans the window, which on a phone is the difference between a document you can read and one you pan across a line at a time. Print layout only; reflow already fits by construction.

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

Featureios,android
Spelling suggestions on the keyboard accessory bar. The red underline was the whole of what a phone user got: the menu that acts on one hangs off a long press, and a long press is not a gesture anyone performs on a word they were not already suspicious of. While the caret sits inside a misspelling the corrections now appear above the keyboard — a real InputAccessoryView on iOS, a bar anchored above the IME on Android — with Ignore and Add beside them. It shows only while there is something to correct, so it costs nothing the rest of the time, and ShowSpellingSuggestions="false" turns it off. See Document Editor.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Initial Release