diff --git a/CHANGELOG.md b/CHANGELOG.md
index f636352..43159bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,11 +24,11 @@ Templates from boards made with the Windows 10 app of Microsoft Whiteboard now c
with their title and the notes, lists, text, pictures, shapes, and ink placed on them.
They used to be listed as left out.
-### Loop components, apps, work items, and frames from Microsoft Whiteboard
+### Loop components, apps, and other objects from Microsoft Whiteboard
A Loop component now comes across as a link to it, an app made by Copilot as an empty
panel of its size, an Azure DevOps work item as a text container with its title, owner,
-and state, and a frame Copilot drew as a panel with its title. They used to be listed as
-left out.
+and state, a frame Copilot drew as a panel with its title, and a custom object as a gray
+box that names it. They used to be listed as left out.
## 1.7.3 - 30 September 2026
diff --git a/docs/microsoft-whiteboard-import.md b/docs/microsoft-whiteboard-import.md
index b163b31..886c349 100644
--- a/docs/microsoft-whiteboard-import.md
+++ b/docs/microsoft-whiteboard-import.md
@@ -73,6 +73,7 @@ no model of the board beyond what it draws.
| `AppIframeHost` | An app that Copilot made, running in a sandboxed `iframe`. The export replaces the frame with a `#faf9f8` panel and keeps the `div.appFrameContainer` with its `width` and `height` and its header, whose `span.appFrameHeaderTitle` says *App* in the board's language. The app's name, code, and state are not in the export. Not seen in a sample; read from the web client's app frame component and export code. |
| `WorkItem` | An Azure DevOps work item: a `div.WorkItem` card, 220 × 145 pixels in the stylesheet, white with a 4-pixel gold border on its left. A `span.WorkItemTextField` holds a `strong` with the type's `img.WorkItemIcon` (its `alt` names the type) and the ID, followed by the title; then come who it is assigned to and, in `span.WorkItemText`, the word *State* in the board's language and the state. The web client draws a placeholder instead when its `EnableWorkItems` flag is off. Not seen in a sample; read from the web client's `WorkItemConnected`. |
| `Frame` | A frame Copilot drew around objects it grouped: a `section.whiteboardFrame` with its `width` and `height` and a theme class, `whiteboardFrame--blue`, `--green`, `--purple`, `--orange`, or neutral without one. The stylesheet gives it a 3-pixel border, a translucent fill, 14-pixel corners, and a 44-pixel title bar with bold 15-pixel text in `span.whiteboardFrame__titleText`, padded 14 pixels in. A pill names the first source and counts the rest; the full list is in a popover that is drawn only when open. The web client draws nothing when its `EnableFrames` flag is off. Not seen in a sample; read from the web client's frame component. |
+| `CustomElement` | A widget from a plug-in, named by its kind in `data-custom-widget-kind`. The web client registers only two examples, `example.counter` and `example.embed`; for anything else, and for every kind when its `EnableCustomElement` flag is off, it draws a gray box that fills the object and says the widget is not available or disabled. The box is at least 120 × 80 pixels, and its own size is `100%`, so the object's size is the first one given in pixels. Not seen in a sample; read from the web client's custom widget component. |
| `CommentThread` | A pin, described above. |
### Ink
@@ -139,6 +140,7 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C
| App made by Copilot | `ShapeBoardObject` rectangle of the frame's size in the panel's color, with the header's title as its text |
| Work item | Markdown `TextBoardObject` titled *Work item*: the type and ID in bold before the title, then who it is assigned to and its state, as wide as the card |
| Copilot frame | `ShapeBoardObject` rectangle with the theme's border and translucent fill, and its title as a bold label in the theme's text color. Its sources are left out |
+| Custom object | `ShapeBoardObject` rectangle of its size in the gray the web client draws, with *Custom object* and the kind as its text |
| Link card | Markdown `TextBoardObject` titled *Link* |
| List | Markdown `TextBoardObject` titled *List*: the list's title in bold, then a Markdown list of bullets, or one line per item with ☒ for a done task and ☐ for an open one |
| Comment thread | Markdown `TextBoardObject` titled *Comment*, in a column to the right of the board |
@@ -243,13 +245,14 @@ parses in about 160 ms.
- SVG pictures, which the Windows client refuses to insert
- Groups, which the Windows client does not offer
- GIFs, lists, ink tables, legacy stickers, legacy templates, Loop components, apps, work
- items, and Copilot frames, which are read from the web client's code, not from a sample
+ items, Copilot frames, and custom objects, which are read from the web client's code, not
+ from a sample
The web client's code names every type an export can hold. Its `data-whiteboard-type`
values, as of version 26.10910.101 in September 2026, are the types above and these,
-which the importer reports and leaves out: `CustomElement`, an `Unknown` that holds no
-table, live content without a Loop element, an app frame without its box, and a work item
-or a frame drawn as a placeholder.
+which the importer reports and leaves out: an `Unknown` that holds no table, live
+content without a Loop element, an app frame without its box, a work item or a frame
+drawn as a placeholder, and a custom object without a kind.
The tokenizer yields text that follows an end tag as a tag named `#text`, because a work
item's title comes after the bold ID.
diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs
index 87e84d5..ad80c8e 100644
--- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs
+++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs
@@ -31,6 +31,15 @@ public sealed partial class MicrosoftWhiteboardExport
private const string LoopTitle = "Loop";
private const string WorkItemTitle = "Work item";
+ ///
+ /// The gray box the web client draws for a custom object it cannot show, and the
+ /// smallest size it gives one.
+ ///
+ private const uint CustomElementPanelArgb = 0xFFF3F2F1;
+ private const uint CustomElementOutlineArgb = 0xFFE1DFDD;
+ private const uint CustomElementTextArgb = 0xFF605E5C;
+ private static readonly PointD CustomElementMinimum = new(120, 80);
+
///
/// A work item card in the web client's stylesheet.
///
@@ -288,6 +297,17 @@ void Skip(string what, int count = 1)
items.Add(link);
}
+ break;
+ case "CustomElement":
+ if (ReadCustomElement(anchor, tags) is { } custom)
+ {
+ items.Add(custom);
+ }
+ else
+ {
+ Skip(anchor.Type);
+ }
+
break;
case "WorkItem":
if (ReadWorkItem(anchor, tags) is { } workItem)
@@ -1369,6 +1389,45 @@ private static IEnumerable ReadFrame(Anchor anchor, Lis
}
}
+ ///
+ /// A custom object is a widget from a plug-in, named by its kind in
+ /// data-custom-widget-kind. The web client registers only examples, and
+ /// draws a gray box saying the widget is disabled or not available for anything
+ /// else, so the widget itself is not in the export. It becomes a rectangle of the
+ /// object's size in that gray, with the kind as its text.
+ ///
+ private static MicrosoftWhiteboardShape? ReadCustomElement(Anchor anchor, List tags)
+ {
+ var kind = tags.Select(tag => tag.Attribute("data-custom-widget-kind"))
+ .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
+ if (kind is null)
+ {
+ return null;
+ }
+
+ // The widget fills its box, so the size is the first one given in pixels.
+ var sized = tags.FirstOrDefault(tag =>
+ PixelLength(tag.Attribute("style"), "width") > 0 && PixelLength(tag.Attribute("style"), "height") > 0);
+ var width = PixelLength(sized?.Attribute("style"), "width") ?? CustomElementMinimum.X;
+ var height = PixelLength(sized?.Attribute("style"), "height") ?? CustomElementMinimum.Y;
+ return new MicrosoftWhiteboardShape(
+ anchor.ToCanvas(anchor.Centered ? default : new PointD(width / 2, height / 2)),
+ width * anchor.Scale,
+ height * anchor.Scale,
+ anchor.AngleDegrees,
+ ShapeKind.Rectangle,
+ CustomElementOutlineArgb,
+ CustomElementPanelArgb,
+ anchor.Scale,
+ $"Custom object\n{kind.Trim()}",
+ new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 14 * anchor.Scale, CustomElementTextArgb, false, false, false));
+ }
+
+ private static double? PixelLength(string? style, string property) =>
+ StyleValue(style, property) is { } value && value.EndsWith("px", StringComparison.OrdinalIgnoreCase)
+ ? StyleLength(style, property)
+ : null;
+
///
/// A connector's SVG draws its route from the anchor, and its head as a small
/// path moved to one end. A route with corners is kept as a straight line.
diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs
index 46bc98f..dc774f0 100644
--- a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs
+++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs
@@ -143,6 +143,7 @@ public static void Run()
RunTemplate();
RunLive();
RunCards();
+ RunCustom();
}
///
@@ -657,6 +658,35 @@ private static void RunCards()
Assert(board.Items.Count == 3, "A frame's sources are left out.");
}
+ ///
+ /// Custom objects as the web client draws them when it cannot show the widget: a
+ /// gray box that fills the object and names its kind.
+ ///
+ private const string CustomPage = """
+
+
+
Widget "example.counter" is disabled
+
+
+
Widget "contoso.poll" is not available
+
+
+
+ """;
+
+ private static void RunCustom()
+ {
+ var board = MicrosoftWhiteboardExport.Parse(CustomPage, "Custom");
+ Assert(board.Skipped.Count == 1 && board.Skipped["CustomElement"] == 1,
+ "A custom object without a kind is still reported.");
+ Assert(board.Items[0] is MicrosoftWhiteboardShape { Kind: ShapeKind.Rectangle, Width: 240, Height: 160, FillArgb: 0xFFF3F2F1, Text: "Custom object\nexample.counter" } counter &&
+ counter.Center == new PointD(220, 180),
+ "A custom object is a gray box of its size that names its kind.");
+ Assert(board.Items[1] is MicrosoftWhiteboardShape { Width: 120, Height: 80, Text: "Custom object\ncontoso.poll" } poll &&
+ poll.Center == new PointD(1000, 1000),
+ "A custom object without a size in pixels takes the smallest box, and a percentage is not a size.");
+ }
+
private static void RunArchive()
{
var folder = Path.Combine(Path.GetTempPath(), "wb-mswb-" + Guid.NewGuid().ToString("N"));