<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Tony Edwards</title>
    <description>A curation of learnings, musings and similarly captured day dreams.</description>
    <link>https://tonyedwardspz.co.uk</link>
    <atom:link href="https://tonyedwardspz.co.uk/feed.xml" rel="self" type="application/rss+xml" />
    
      <item>
        <title>Zurich JS took the roof off</title>
        <description>&lt;p&gt;Zurich. You rocked!&lt;/p&gt;

&lt;p&gt;And nn a way most communities struggle to do after a few events. Let alone the first one.&lt;/p&gt;

&lt;p&gt;Friday saw the inaugural &lt;strong&gt;ZurichJS&lt;/strong&gt; conference happen in the Swiss capital, bringing together a handful of speakers from around the globe to educate and entertains hundreds of developers from central Europe and beyond. The crowd walked away with enough knowledge and insight into the depths of the JavaScript ecosystem to shape the next few months of development time.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2026/zurichjs-crowd.jpg&quot; alt=&quot;A packed room at Zurich JS&quot; /&gt;&lt;/p&gt;

&lt;p&gt;But this is just the start.&lt;/p&gt;

&lt;p&gt;It was the first large scale event from this community. But you wouldn’t have known it. The lineup was straight out of a A* conference. The level of polish exceeded even the biggest and most established community events of this ilk. Every touch point had a level of polish that we organisers would love to incorporate into our own gatherings, but fail to find the time for. And I’m sure the audience would agree for their interactions as well. Being trusted with one half of the hosting duties truly was an honor.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2026/zurichjs-selfie.jpg&quot; alt=&quot;On hosting duties at Zurich JS&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Zurich itself is a beautiful place, filled with friendly faces much like the event. As long as you don’t want to be singing the earworm that is baby shark late at night (I’m looking at you Salih 👀), you’ll have no problems as a stranger in the city. Great public transport combine with wonderful sightseeing opportunities, and people who will tolerate pigeon German with a smile on their face.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2026/zurichjs-zurich.jpg&quot; alt=&quot;The Zurich skyline&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Its a wonderful culture to experience, and I learned a lot about from the stage and beyond:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;3 minutes behind schedule is a much bigger thing to the Swiss than it is to most&lt;/li&gt;
  &lt;li&gt;The people are much less reserved than most audiences on the continent&lt;/li&gt;
  &lt;li&gt;Cheese is a way of life, not an ingredient&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’m fully on board with the latter 🧀&lt;/p&gt;

&lt;p&gt;More importantly, the tech lessons dispensed from the stage are sure to have taken the rooms plans for the year ahead up a notch. With cutting edge CSS from &lt;a href=&quot;https://www.youtube.com/@KevinPowell&quot;&gt;Kevin Powell&lt;/a&gt;, deep debugging tips from &lt;a href=&quot;https://www.linkedin.com/in/markerikson/&quot;&gt;Mark Erikson&lt;/a&gt;, and lessons in taste from &lt;a href=&quot;https://www.linkedin.com/in/tejasq/&quot;&gt;Tejas Kumar&lt;/a&gt; to name a few of the sessions, we collectively walked away with knowledge you can’t simply summon from the depths of an LLM.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2026/zurichjs-kevin.jpg&quot; alt=&quot;Kevin Powell at Zurich JS&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2026/zurichjs-mark.jpg&quot; alt=&quot;Mark Erikson at Zurich JS&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2026/zurichjs-tejas.jpg&quot; alt=&quot;Tejas Kumar at Zurich JS&quot; /&gt;&lt;/p&gt;

&lt;p&gt;And that’s the whole point of conferences.&lt;/p&gt;

&lt;p&gt;To learn what you didn’t know you needed to learn.&lt;/p&gt;

&lt;p&gt;Whatever your training budget is for events is, invest it in this one and make the effort to take the trip. It was perfectly executed, with an abundance of friendly faces milling around the venue creating the buzz of a community that has been thirsting for something like this for a very long time.&lt;/p&gt;

&lt;p&gt;Knowing the level of effort the organisers put into this one, the quality of talks they’ll look to curate again, and some spilled tea from Mummy Faris, the next edition is going to surpass this one by a long stretch.&lt;/p&gt;

&lt;p&gt;That I’m sure of.&lt;/p&gt;

&lt;p&gt;What I’m not so sure of is if we can surpass the noise level in the room at the end of the day. At 105db, it was litterally hazrdous to health.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2026/zurichjs-health-warning.jpg&quot; alt=&quot;Zurich JS is hazardous to your health&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Regardless, we will try.&lt;/p&gt;

&lt;p&gt;Sorry to the audience if you walked away from the event with sore hands and throats from all the clapping and cheering. Consider it yet another contribution to your amazing community on the big day.&lt;/p&gt;

&lt;p&gt;Awesome work Züri 🙌&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2026/zurichjs-organising-team.jpg&quot; alt=&quot;The Zurich JS organising team&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Host photo by &lt;a href=&quot;https://www.linkedin.com/in/brandtcodes/&quot;&gt;Alexander Brandt&lt;/a&gt; and noise warning photo by &lt;a href=&quot;https://www.linkedin.com/in/tejasq/&quot;&gt;Tejas Kumar&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
        <pubDate>Fri, 11 Sep 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/zurich-js-rocked/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/zurich-js-rocked/</guid>
      </item>
    
      <item>
        <title>Login with Passkeys in a .NET MAUI App</title>
        <description>&lt;p&gt;I’ve been digging through the .NET MAUI Preview 7 changes, and there’s one addition that deserves a bit more attention than a bullet point in some release notes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;.NET MAUI now has a cross-platform Passkeys API.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not a wrapper around a web page. Not “open the browser, authenticate and come back”. It actually talks to the native passkey APIs on iOS, Android and Windows. Pleasingly, the API itself is tiny.&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;Passkeys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IsSupported&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;Passkeys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;CreateAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(...)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;Passkeys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AssertAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That’s pretty much the MAUI side of it.&lt;/p&gt;

&lt;p&gt;Of course, anyone who’s implemented authentication before will immediately recognise the trap hiding in that sentence.&lt;/p&gt;

&lt;p&gt;The client API might be tiny. Passkeys themselves are not.&lt;/p&gt;

&lt;p&gt;So I went through the new MAUI sample and the accompanying ASP.NET Core server plus the API design to see what a real implementation actually looks like.&lt;/p&gt;

&lt;p&gt;The good news is that the split is sensible. MAUI handles the awkward platform-specific bit. Your server handles the security-sensitive WebAuthn bit. That gives us something we can actually build with.&lt;/p&gt;

&lt;h2 id=&quot;what-maui-is-doing&quot;&gt;What MAUI is doing&lt;/h2&gt;

&lt;p&gt;The new API lives in:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;Microsoft.Maui.Authentication&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;and is essentially a bridge between your server and the platform authenticator.&lt;/p&gt;

&lt;p&gt;The overall registration flow looks like this:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;MAUI app
   |
   | Ask server to begin registration
   v
Server
   |
   | WebAuthn creation options JSON
   v
MAUI Passkeys.CreateAsync()
   |
   | Native Face ID / fingerprint / PIN / Windows Hello UI
   v
Platform authenticator
   |
   | WebAuthn credential response
   v
MAUI app
   |
   | Send response to server
   v
Server verifies it and stores the passkey
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Authentication is basically the same thing in reverse:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;MAUI app
   |
   | Ask server to begin authentication
   v
Server
   |
   | WebAuthn request options JSON
   v
MAUI Passkeys.AssertAsync()
   |
   | Native authentication UI
   v
Platform authenticator
   |
   | Signed assertion
   v
MAUI app
   |
   | Send assertion to server
   v
Server verifies it and signs the user in
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That JSON-in, JSON-out design is deliberate. MAUI doesn’t generate challenges, verify signatures or decide whether somebody should be allowed into your application. Nor should it.&lt;/p&gt;

&lt;p&gt;The private key also stays with the platform authenticator. Your app is shuffling WebAuthn messages around, rather than becoming responsible for storing some exciting new authentication secret that we’ll all regret in six months.&lt;/p&gt;

&lt;h2 id=&quot;platform-support&quot;&gt;Platform support&lt;/h2&gt;

&lt;p&gt;In Preview 7, the built-in implementation covers:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Android 14 / API 34+&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;iOS and iPadOS 16+&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Mac Catalyst 16+&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Windows 10 version 1903+&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can check that at runtime:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Passkeys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IsSupported&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// Show your normal login flow.&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;One important detail: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IsSupported&lt;/code&gt; tells you that the OS has the necessary passkey support. It doesn’t tell you that you’ve configured everything correctly, as with other IsSupported properties.&lt;/p&gt;

&lt;p&gt;Your app and your authentication domain still have to trust each other, which we’ll get to shortly.&lt;/p&gt;

&lt;h2 id=&quot;lets-actually-implement-it&quot;&gt;Let’s actually implement it&lt;/h2&gt;

&lt;p&gt;I’m going to assume we already have:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;a .NET MAUI application;&lt;/li&gt;
  &lt;li&gt;an HTTPS API, perhaps at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://auth.example.com&lt;/code&gt;;&lt;/li&gt;
  &lt;li&gt;users who can already authenticate;&lt;/li&gt;
  &lt;li&gt;some mechanism for issuing access/refresh tokens or sessions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We’re adding passkeys to that rather than designing authentication from scratch. The MAUI sample uses ASP.NET Core Identity on its backend, so I’ll do the same here.&lt;/p&gt;

&lt;h3 id=&quot;step-1-configure-the-relying-party-on-the-server&quot;&gt;Step 1: configure the relying party on the server&lt;/h3&gt;

&lt;p&gt;The first thing your server needs to know is its relying-party ID.&lt;/p&gt;

&lt;p&gt;If our authentication server is:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;https://auth.example.com
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;then the RP ID would normally be:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;auth.example.com
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;With ASP.NET Core Identity:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;builder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Configure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IdentityPasskeyOptions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;options&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ServerDomain&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;auth.example.com&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ValidateOrigin&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;context&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;allowedOrigins&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;https://auth.example.com&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;

            &lt;span class=&quot;c1&quot;&gt;// Android&apos;s origin will also go here.&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;android:apk-key-hash:YOUR_APP_SIGNING_HASH&quot;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ValueTask&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;FromResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;allowedOrigins&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Contains&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Origin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ValidateOrigin&lt;/code&gt; is worth noticing. A native application doesn’t have a browser page URL from which WebAuthn can derive its origin.&lt;/p&gt;

&lt;p&gt;Apple effectively uses your associated HTTPS domain. Windows uses the RP HTTPS origin. Android identifies the application using an origin based on the hash of its signing certificate:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;android:apk-key-hash:&amp;lt;base64url-sha256-signing-certificate&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The server therefore needs to accept the appropriate origin for each native application allowed to authenticate against it. You’ll also need the Identity schema capable of storing passkeys. The MAUI sample enables Identity schema version 3:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;builder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddIdentityCore&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ApplicationUser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;options&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Stores&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SchemaVersion&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;IdentitySchemaVersions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Version3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddEntityFrameworkStores&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ApplicationDbContext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;()&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddSignInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Unlike the MAUI sample, your production database obviously shouldn’t be an in-memory SQLite database that disappears whenever somebody looks at the server funny. Use your normal persistent Identity store and migrations. Security practices are just as important with passkeys as they are with passwords.&lt;/p&gt;

&lt;h2 id=&quot;step-2-create-the-registration-begin-endpoint&quot;&gt;Step 2: create the registration begin endpoint&lt;/h2&gt;

&lt;p&gt;A passkey normally gets added to an account that we already trust. So imagine somebody has logged into the app normally and tapped:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add a passkey&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The MAUI app first asks the server to start a registration ceremony.&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/passkeys/register/begin&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;HttpContext&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;UserManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ApplicationUser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;SignInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ApplicationUser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;signInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetUserAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Unauthorized&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userId&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetUserIdAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userName&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetUserNameAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;InvalidOperationException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;User has no username.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;optionsJson&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;signInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MakePasskeyCreationOptionsAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PasskeyUserEntity&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;Id&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;Name&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;DisplayName&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userName&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;optionsJson&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireAuthorization&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MakePasskeyCreationOptionsAsync()&lt;/code&gt; creates the standard WebAuthn &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PublicKeyCredentialCreationOptions&lt;/code&gt; that the authenticator needs. This contains things such as the challenge and relying-party details.&lt;/p&gt;

&lt;p&gt;The important bit is that &lt;strong&gt;the server creates them&lt;/strong&gt;. Don’t generate a challenge in your MAUI application and don’t try to manufacture this JSON yourself.&lt;/p&gt;

&lt;h2 id=&quot;step-3-ask-maui-to-create-the-passkey&quot;&gt;Step 3: ask MAUI to create the passkey&lt;/h2&gt;

&lt;p&gt;Back in the app:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;Microsoft.Maui.Authentication&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;System.Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;CreatePasskeyAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;CancellationToken&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cancellationToken&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Passkeys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IsSupported&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;FeatureNotSupportedException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;Passkeys aren&apos;t supported on this device.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;beginResponse&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;httpClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;PostAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;/passkeys/register/begin&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;cancellationToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;beginResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;EnsureSuccessStatusCode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;creationOptionsJson&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;beginResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ReadAsStringAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;cancellationToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;passkeyResponse&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Passkeys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;CreateAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;creationOptionsJson&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;cancellationToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;StringContent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;passkeyResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;Encoding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UTF8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finishResponse&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;httpClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;PostAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;/passkeys/register/finish&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;cancellationToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;finishResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;EnsureSuccessStatusCode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;There’s one slightly subtle thing in there.&lt;/p&gt;

&lt;p&gt;This:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;passkeyResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;isn’t just a nice debugging representation of the response. It is the actual WebAuthn JSON that needs to go back to your server.&lt;/p&gt;

&lt;p&gt;So don’t do this:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;httpClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;PostAsJsonAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;/passkeys/register/finish&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;passkeyResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That would serialise your JSON &lt;em&gt;string&lt;/em&gt; and leave your server with one big escaped JSON value. Send it as raw &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;application/json&lt;/code&gt;. The MAUI API was deliberately designed so the result of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CreateAsync()&lt;/code&gt; can effectively be passed straight back to the relying party.&lt;/p&gt;

&lt;p&gt;At this point MAUI will invoke the platform UI.&lt;/p&gt;

&lt;p&gt;Depending on the device, that’ll mean Face ID, Touch ID, fingerprint, PIN, Windows Hello or whatever authenticator the platform has available. There is no platform-specific code in our ViewModel, which is the point.&lt;/p&gt;

&lt;h2 id=&quot;step-4-verify-and-store-the-new-passkey&quot;&gt;Step 4: verify and store the new passkey&lt;/h2&gt;

&lt;p&gt;Now we need &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/passkeys/register/finish&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The MAUI sample uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PerformPasskeyAttestationAsync()&lt;/code&gt; to validate the result:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/passkeys/register/finish&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;HttpContext&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;JsonElement&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;credential&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;UserManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ApplicationUser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;SignInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ApplicationUser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;signInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetUserAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Unauthorized&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;attestation&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;signInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;PerformPasskeyAttestationAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;credential&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetRawText&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;attestation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Succeeded&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;BadRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;error&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;attestation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Failure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Message&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userId&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetUserIdAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(!&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Equals&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;userId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;attestation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UserEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;StringComparison&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Ordinal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;BadRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;The passkey doesn&apos;t belong to the authenticated user.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddOrUpdatePasskeyAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;attestation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Passkey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Succeeded&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;BadRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Ok&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireAuthorization&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;There are two separate security checks happening here. First, Identity verifies the WebAuthn attestation. Then we’re making sure the identity represented by that ceremony matches the user who is currently authenticated.&lt;/p&gt;

&lt;p&gt;Only after both of those things have happened do we store the credential. The stored record contains the public-key information needed to verify future assertions. The private key remains with the user’s authenticator.&lt;/p&gt;

&lt;h2 id=&quot;step-5-start-a-passkey-login&quot;&gt;Step 5: start a passkey login&lt;/h2&gt;

&lt;p&gt;Now we can get rid of the password. Or at least stop asking for it every five minutes.&lt;/p&gt;

&lt;p&gt;Our login begin endpoint doesn’t even need a username:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/passkeys/login/begin&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;SignInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ApplicationUser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;signInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;optionsJson&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;signInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MakePasskeyRequestOptionsAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;optionsJson&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Passing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;null&lt;/code&gt; means we’re asking for a discoverable, username-less authentication.&lt;/p&gt;

&lt;p&gt;The OS can show the user the passkeys available for this relying party and the credential itself gives the server enough information to work out which user is authenticating.&lt;/p&gt;

&lt;p&gt;Your login screen can effectively become:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Sign in with a passkey

or

Sign in another way
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;rather than asking for an email address before we even know whether we need it.&lt;/p&gt;

&lt;h2 id=&quot;step-6-authenticate-with-the-passkey-in-maui&quot;&gt;Step 6: authenticate with the passkey in MAUI&lt;/h2&gt;

&lt;p&gt;The client side looks remarkably similar to registration.&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Task&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AuthTokens&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;SignInWithPasskeyAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;CancellationToken&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cancellationToken&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Passkeys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IsSupported&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;FeatureNotSupportedException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;Passkeys aren&apos;t supported on this device.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;beginResponse&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;httpClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;PostAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;/passkeys/login/begin&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;cancellationToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;beginResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;EnsureSuccessStatusCode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;requestOptionsJson&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;beginResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ReadAsStringAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;cancellationToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;assertion&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Passkeys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AssertAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;requestOptionsJson&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;cancellationToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;StringContent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;assertion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;Encoding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UTF8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finishResponse&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;httpClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;PostAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;/passkeys/login/finish&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;cancellationToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;finishResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;EnsureSuccessStatusCode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finishResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AuthTokens&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;cancellationToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;InvalidOperationException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;The server didn&apos;t return authentication tokens.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Again, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AssertAsync()&lt;/code&gt; is where the native UI appears.&lt;/p&gt;

&lt;p&gt;And again, the app doesn’t verify the result. It sends it back to the server.&lt;/p&gt;

&lt;h2 id=&quot;step-7-verify-the-assertion-and-issue-your-normal-tokens&quot;&gt;Step 7: verify the assertion and issue your normal tokens&lt;/h2&gt;

&lt;p&gt;Here’s one place where I’d deliberately diverge from the MAUI sample.&lt;/p&gt;

&lt;p&gt;The sample calls:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;signInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;PasskeySignInAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;and uses an ASP.NET Core authentication cookie. That’s perfectly reasonable for a sample and also perfectly reasonable for a cookie-based web application. The PR itself points out that the sample’s cookie-based native authentication setup is there for convenience rather than being a recommendation for production mobile applications.&lt;/p&gt;

&lt;p&gt;If our existing mobile API normally returns an access token and refresh token, I’d keep doing exactly that. ASP.NET Core Identity also exposes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PerformPasskeyAssertionAsync()&lt;/code&gt;, which verifies the passkey without automatically creating the cookie-based login session.&lt;/p&gt;

&lt;p&gt;That gives us something along these lines:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/passkeys/login/finish&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;JsonElement&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;credential&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;UserManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ApplicationUser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;SignInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ApplicationUser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;signInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;ITokenService&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tokenService&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;assertion&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;signInManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;PerformPasskeyAssertionAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;credential&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetRawText&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;assertion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Succeeded&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;||&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;assertion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;User&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;||&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;assertion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Passkey&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Unauthorized&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;// Persist any updated passkey data, including the&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// authenticator&apos;s updated signature counter.&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;updateResult&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddOrUpdatePasskeyAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;assertion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;assertion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Passkey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;updateResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Succeeded&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;BadRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;// Run the same account/lockout/policy checks your&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// normal authentication flow performs here.&lt;/span&gt;

    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tokens&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tokenService&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;CreateTokensAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;assertion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Ok&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tokens&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That’s an important architectural point. Passkeys don’t need to replace your authentication system. They’re another way of proving who the user is. Once the server has successfully verified that proof, you can feed the resulting user into the same token/session machinery you already use.&lt;/p&gt;

&lt;p&gt;ASP.NET Core’s lower-level assertion API is specifically available for cases where you need that extra control. It also requires you to persist the returned updated passkey information yourself.&lt;/p&gt;

&lt;h2 id=&quot;one-slightly-annoying-detail-keep-the-ceremony-state&quot;&gt;One slightly annoying detail: keep the ceremony state&lt;/h2&gt;

&lt;p&gt;There is another bit in the sample that’s easy to overlook. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/begin&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/finish&lt;/code&gt; calls belong to the same WebAuthn ceremony.&lt;/p&gt;

&lt;p&gt;ASP.NET Core Identity stores temporary passkey state so that when the response comes back it can confirm it corresponds to the challenge that was originally generated.&lt;/p&gt;

&lt;p&gt;The MAUI sample preserves that state using an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HttpClient&lt;/code&gt; backed by a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CookieContainer&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;handler&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpClientHandler&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;CookieContainer&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;CookieContainer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;UseCookies&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;httpClient&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;HttpClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;handler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;BaseAddress&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;Uri&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;https://auth.example.com/&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That doesn’t mean your application’s main authentication mechanism needs to use cookies. Your normal API requests can still use:&lt;/p&gt;

&lt;div class=&quot;language-http highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;err&quot;&gt;Authorization: Bearer ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The cookie here can simply correlate the short-lived WebAuthn ceremony.&lt;/p&gt;

&lt;p&gt;If your authentication infrastructure already has another mechanism for storing challenge state such as a server-side ceremony ID, you can use that instead. What matters is that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/finish&lt;/code&gt; can securely recover the state created by the corresponding &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/begin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Lose that and verification should fail.&lt;/p&gt;

&lt;h2 id=&quot;apple-needs-associated-domains&quot;&gt;Apple needs Associated Domains&lt;/h2&gt;

&lt;p&gt;We’re not quite done. This stuff deliberately won’t work just because you’ve pointed an app at a server. On Apple platforms, your application needs the Associated Domains entitlement:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;key&amp;gt;&lt;/span&gt;com.apple.developer.associated-domains&lt;span class=&quot;nt&quot;&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;string&amp;gt;&lt;/span&gt;webcredentials:auth.example.com&lt;span class=&quot;nt&quot;&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And your server needs to expose:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;https://auth.example.com/.well-known/apple-app-site-association
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;with something like:&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;webcredentials&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;apps&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;ABCDE12345.com.example.myapp&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;where the value is your:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&amp;lt;Team ID&amp;gt;.&amp;lt;Bundle ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The app must also be signed with a provisioning profile that includes Associated Domains. Apple then checks that the application’s entitlement and the domain’s association file agree before allowing the app to create or use a passkey for that domain.&lt;/p&gt;

&lt;h2 id=&quot;android-needs-digital-asset-links&quot;&gt;Android needs Digital Asset Links&lt;/h2&gt;

&lt;p&gt;Android has a similar relationship. Your authentication domain needs:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;https://auth.example.com/.well-known/assetlinks.json
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;containing the package and signing certificate allowed to use credentials for that domain:&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;relation&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;delegate_permission/common.get_login_creds&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;target&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;namespace&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;android_app&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;package_name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;com.example.myapp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;sha256_cert_fingerprints&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;AA:BB:CC:...&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The certificate fingerprint needs to be for the certificate actually signing your production application. Not your debug certificate.&lt;/p&gt;

&lt;p&gt;That seems obvious when written down here. It’ll feel slightly less obvious when you’ve spent an afternoon wondering why the thing worked perfectly in development.&lt;/p&gt;

&lt;p&gt;Also worth knowing: the MAUI implementation deliberately uses the OS-native Android Credential Manager path and doesn’t pull Google Play Services into Essentials. As a result, the built-in implementation starts at Android 14/API 34. Support for Android 9–13 can potentially be added by the application through the Play Services Credential Manager adapter, but MAUI doesn’t force that dependency on everybody.&lt;/p&gt;
&lt;h2 id=&quot;a-useful-extra-keep-the-authentication-local&quot;&gt;A useful extra: keep the authentication local&lt;/h2&gt;

&lt;p&gt;There is one extra option in the new API that I quite like. Rather than using the convenient string overload:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Passkeys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AssertAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;requestOptionsJson&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;you can construct the options yourself:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;options&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;PasskeyRequestOptions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;requestOptionsJson&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;PreferImmediatelyAvailable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;assertion&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Passkeys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AssertAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PreferImmediatelyAvailable&lt;/code&gt; tells Android and Apple to only offer a credential that’s immediately available on this device rather than falling through into the cross-device flows involving QR codes or another nearby device.&lt;/p&gt;

&lt;p&gt;If there isn’t one, the operation fails quickly.&lt;/p&gt;

&lt;p&gt;I can see this being particularly useful when a passkey button is one option amongst several on a login screen and you don’t want pressing it to start an entire “find another device” adventure.&lt;/p&gt;

&lt;h2 id=&quot;handle-cancellation-properly&quot;&gt;Handle cancellation properly&lt;/h2&gt;

&lt;p&gt;Passkey UI is also cancellable. I’d make sure your ViewModel doesn’t treat somebody closing Face ID as if the authentication server has exploded:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;passkeyService&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;SignInWithPasskeyAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;cancellationToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TaskCanceledException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// The user cancelled. That&apos;s fine.&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FeatureNotSupportedException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// Fall back to your other login methods.&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ArgumentException&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// Bad WebAuthn options from the server.&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;InvalidOperationException&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// No credential, association problem or&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// another platform ceremony failure.&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;MAUI deliberately normalises the platform-specific errors into normal .NET exception types, with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TaskCanceledException&lt;/code&gt; representing both user and programmatic cancellation.&lt;/p&gt;

&lt;h2 id=&quot;what-id-actually-ship&quot;&gt;What I’d actually ship&lt;/h2&gt;

&lt;p&gt;Once you strip the sample plumbing away, the shape of this isn’t particularly frightening.&lt;/p&gt;

&lt;p&gt;For registration:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Authenticated user
    ↓
POST /passkeys/register/begin
    ↓
Passkeys.CreateAsync()
    ↓
POST /passkeys/register/finish
    ↓
Server verifies + stores public credential
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;For authentication:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;POST /passkeys/login/begin
    ↓
Passkeys.AssertAsync()
    ↓
POST /passkeys/login/finish
    ↓
Server verifies credential
    ↓
Existing access/refresh token flow
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then add the platform-domain association required by Apple and Android.&lt;/p&gt;

&lt;p&gt;That’s it.&lt;/p&gt;

&lt;p&gt;There is obviously security-sensitive machinery underneath all of this. WebAuthn challenges, signatures, attestation, credential IDs, origins, relying-party IDs and replay protection. . . but that’s precisely why I like the design.&lt;/p&gt;

&lt;p&gt;The MAUI app isn’t expected to understand most of it.&lt;/p&gt;

&lt;p&gt;It asks the server what the authenticator should do:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;options&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;server&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;BeginPasskeyLoginAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;lets the operating system do it:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Passkeys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AssertAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;and asks the server whether the result is legitimate:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;server&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;FinishPasskeyLoginAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That’s a pretty good abstraction. And perhaps more importantly, it means we can now put a genuinely native passkey login in a .NET MAUI application without writing separate integrations for AuthenticationServices, Android Credential Manager and Windows WebAuthn.&lt;/p&gt;

&lt;p&gt;That’s the sort of Essentials API I like.&lt;/p&gt;

&lt;p&gt;Small on the surface.&lt;/p&gt;

&lt;p&gt;A frankly unreasonable amount of platform-specific faff underneath it.&lt;/p&gt;

&lt;p&gt;And none of that faff needs to be in my my code.&lt;/p&gt;

&lt;h3 id=&quot;useful-links&quot;&gt;Useful links&lt;/h3&gt;

&lt;p&gt;The implementation landed in .NET MAUI through &lt;a href=&quot;https://github.com/dotnet/maui/pull/36837&quot;&gt;PR #36837 — Passkeys (WebAuthn/FIDO2) Essentials API&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Microsoft has also published the full &lt;a href=&quot;https://github.com/dotnet/maui/blob/release/11.0.1xx-preview7/src/Essentials/samples/README-Passkeys.md&quot;&gt;MAUI Passkeys sample README&lt;/a&gt; and the &lt;a href=&quot;https://github.com/dotnet/maui/tree/release/11.0.1xx-preview7/src/Essentials/samples/Samples.Server.Passkeys&quot;&gt;reference ASP.NET Core backend&lt;/a&gt;.&lt;/p&gt;
</description>
        <pubDate>Sun, 23 Aug 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/login-with-passkeys-in-a-MAUI-app/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/login-with-passkeys-in-a-MAUI-app/</guid>
      </item>
    
      <item>
        <title>Recording Web Speech Synthesis</title>
        <description>&lt;p&gt;I usually remember that I need a recording of some synthesised speech shortly before giving each rendition of my &lt;em&gt;Beats, Rhymes and Neural Nets&lt;/em&gt; talk.&lt;/p&gt;

&lt;p&gt;By “shortly before”, I mean I’m sitting in a hotel room, playing speech through a browser and recording it as a voice note on my phone. I then have to transfer the file to my laptop over whatever the hotel considers Wi-Fi.&lt;/p&gt;

&lt;p&gt;It’s not a distinguished workflow.&lt;/p&gt;

&lt;p&gt;Given I’m trying to be a little more prepared for future talks, I decided to add recording directly to the app that goes with it. Type the line, choose a voice, press Speak and download the result.&lt;/p&gt;

&lt;p&gt;That was the idea, anyway.&lt;/p&gt;

&lt;p&gt;The awkward bit is that the Web Speech API’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;speechSynthesis&lt;/code&gt; interface doesn’t give you an audio stream. You can ask it to speak, but you can’t take its output and pass it directly to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MediaRecorder&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My first workaround appeared to succeed. Chrome produced a playable WebM file with a sensible duration and no errors.&lt;/p&gt;

&lt;p&gt;But it was completely silent.&lt;/p&gt;

&lt;h2 id=&quot;why-recording-the-chrome-tab-didnt-work&quot;&gt;Why recording the Chrome tab didn’t work&lt;/h2&gt;

&lt;p&gt;I initially tried capturing the current tab’s audio with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;getDisplayMedia()&lt;/code&gt;. That seemed reasonable. The page starts the speech, the speech comes out of Chrome and Chrome records the tab.&lt;/p&gt;

&lt;p&gt;Except that isn’t necessarily the route the audio takes.&lt;/p&gt;

&lt;p&gt;Chrome voices commonly use the operating system’s speech engine. The page requests an utterance, but the resulting sound is produced outside the tab’s audio pipeline. Tab capture therefore records the tab perfectly while missing the voice I actually care about.&lt;/p&gt;

&lt;p&gt;The solution was to record the operating system’s mixed audio through screen sharing, rather than recording only the Chrome tab.&lt;/p&gt;

&lt;h2 id=&quot;making-speech-synthesis-awaitable&quot;&gt;Making speech synthesis awaitable&lt;/h2&gt;

&lt;p&gt;Before dealing with capture, I needed to know when an utterance had finished.&lt;/p&gt;

&lt;p&gt;The normal &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;speechSynthesis.speak()&lt;/code&gt; method starts speaking and returns immediately. Wrapping the utterance events in a Promise gives the recording code a clean boundary to work with:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;export const speakAsync = (options) =&amp;gt; {
  const utter = createUtterance(options);

  if (!utter) {
    return Promise.resolve();
  }

  return new Promise((resolve, reject) =&amp;gt; {
    let settled = false;

    utter.onend = () =&amp;gt; {
      if (settled) {
        return;
      }

      settled = true;
      resolve();
    };

    utter.onerror = (event) =&amp;gt; {
      if (settled) {
        return;
      }

      settled = true;

      const reason = event?.error || &apos;synthesis-failed&apos;;
      const error = new Error(
        `Speech synthesis failed: ${reason}`
      );

      error.name =
        reason === &apos;canceled&apos; || reason === &apos;interrupted&apos;
          ? &apos;SpeechCancelledError&apos;
          : &apos;SpeechSynthesisError&apos;;

      reject(error);
    };

    window.speechSynthesis.cancel();
    window.speechSynthesis.speak(utter);
  });
};
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The important part is that the recording flow can now do this:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;startRecording();
await speakAsync(options);
stopRecording();
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Cancellation and interruption get their very own error type because pressing Stop isn’t really a failed recording. Nor is replacing one utterance with another.&lt;/p&gt;

&lt;h2 id=&quot;asking-for-system-audio&quot;&gt;Asking for system audio&lt;/h2&gt;

&lt;p&gt;The capture options now steer Chrome away from the current tab and towards an entire-screen share with system audio:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;const buildDisplayMediaOptions = () =&amp;gt; {
  const supported =
    typeof navigator.mediaDevices.getSupportedConstraints ===
    &apos;function&apos;
      ? navigator.mediaDevices.getSupportedConstraints()
      : {};

  const audio = {};

  if (supported.suppressLocalAudioPlayback) {
    audio.suppressLocalAudioPlayback = false;
  }

  if (supported.restrictOwnAudio) {
    audio.restrictOwnAudio = false;
  }

  return {
    video: true,
    audio: Object.keys(audio).length ? audio : true,
    preferCurrentTab: false,
    selfBrowserSurface: &apos;include&apos;,
    systemAudio: &apos;include&apos;,
    monitorTypeSurfaces: &apos;include&apos;,
  };
};
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;suppressLocalAudioPlayback: false&lt;/code&gt; allows me to keep hearing the speech while it’s recorded. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;restrictOwnAudio: false&lt;/code&gt; asks the browser not to remove the page’s own audio from the mix.&lt;/p&gt;

&lt;p&gt;The remaining options are hints for Chrome’s sharing picker. They don’t let the application quietly choose a screen or enable system audio on someone’s behalf, which is probably for the best.&lt;/p&gt;

&lt;p&gt;The user still has to select something along the lines of:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Entire screen → Share system audio&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Choose “Chrome tab” and the result may still be silence.&lt;/p&gt;

&lt;h2 id=&quot;throwing-away-the-video&quot;&gt;Throwing away the video&lt;/h2&gt;

&lt;p&gt;Calling &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;getDisplayMedia()&lt;/code&gt; gives me a display stream containing video and, if the correct option was selected, system audio.&lt;/p&gt;

&lt;p&gt;I don’t need the video. Once permission has been granted, I stop and remove those tracks immediately:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;const ensureAudioTracks = (captureStream, missingMessage) =&amp;gt; {
  for (const track of captureStream.getVideoTracks()) {
    track.stop();
    captureStream.removeTrack(track);
  }

  const audioTracks = captureStream.getAudioTracks();

  if (!audioTracks.length) {
    stopMediaStream(captureStream);

    const error = new Error(missingMessage);
    error.name = &apos;TabAudioMissingError&apos;;
    throw error;
  }

  return new MediaStream(audioTracks);
};
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Apart from doing less unnecessary work, this reduces the amount of the shared display that remains active.&lt;/p&gt;

&lt;p&gt;The code keeps references to both the original capture stream and the audio-only stream. That matters later when capture stops, the component unmounts or the user starts a new session. Media tracks are quite happy to carry on until somebody explicitly tells them otherwise.&lt;/p&gt;

&lt;h2 id=&quot;starting-the-recorder-first&quot;&gt;Starting the recorder first&lt;/h2&gt;

&lt;p&gt;The order of operations matters.&lt;/p&gt;

&lt;p&gt;Starting speech and then creating &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MediaRecorder&lt;/code&gt; risks losing the beginning of the utterance. The recorder needs to be running before &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;speechSynthesis.speak()&lt;/code&gt; is called:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;const session = startAudioRecording(audioStream);
activeRecordingRef.current = session;
setIsRecording(true);

await speakAsync(options);
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;When the utterance ends, I wait another 120 milliseconds before stopping the recorder:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;await new Promise((resolve) =&amp;gt; {
  window.setTimeout(resolve, 120);
});

const result = await session.stop();
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That small delay isn’t decorative. The utterance’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;end&lt;/code&gt; event can arrive slightly before the final audio reaches &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MediaRecorder&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;picking-a-format-the-browser-can-record&quot;&gt;Picking a format the browser can record&lt;/h2&gt;

&lt;p&gt;The implementation also checks which MIME types are available instead of assuming every browser can create the same format:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;const RECORDER_MIME_CANDIDATES = [
  &apos;audio/webm;codecs=opus&apos;,
  &apos;audio/webm&apos;,
  &apos;audio/mp4&apos;,
];

export const pickRecorderMimeType = () =&amp;gt; {
  if (
    typeof MediaRecorder === &apos;undefined&apos; ||
    !MediaRecorder.isTypeSupported
  ) {
    return undefined;
  }

  return RECORDER_MIME_CANDIDATES.find((type) =&amp;gt;
    MediaRecorder.isTypeSupported(type)
  );
};
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MediaRecorder&lt;/code&gt; emits chunks while it runs. I collect those chunks and turn them into a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Blob&lt;/code&gt; when recording stops:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;recorder.ondataavailable = (event) =&amp;gt; {
  if (event.data &amp;amp;&amp;amp; event.data.size &amp;gt; 0) {
    chunks.push(event.data);
  }
};

recorder.onstop = () =&amp;gt; {
  const blob = new Blob(chunks, { type: mimeType });

  resolve({
    blob,
    mimeType,
    discarded: false,
  });
};

recorder.start(250);
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The 250-millisecond timeslice encourages Chromium to flush chunks during longer utterances instead of keeping everything until the end. Depending on the selected MIME type, the download gets a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.webm&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.m4a&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ogg&lt;/code&gt; extension.&lt;/p&gt;

&lt;h2 id=&quot;detecting-a-recording-full-of-nothing&quot;&gt;Detecting a recording full of nothing&lt;/h2&gt;

&lt;p&gt;My failed tab-capture experiment exposed another problem: checking that the Blob exists isn’t enough.&lt;/p&gt;

&lt;p&gt;A WebM can have a header, timing information and plenty of bytes while containing no audible speech. To catch that, you need to decode each recording and calculates its root mean square amplitude:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;export const looksSilentRecording = async (
  blob,
  rmsThreshold = 0.008
) =&amp;gt; {
  if (!blob || blob.size &amp;lt; 256) {
    return true;
  }

  const Ctx = AudioContext || webkitAudioContext;
  const context = new Ctx();

  try {
    const buffer = await context.decodeAudioData(
      await blob.arrayBuffer()
    );

    let peakRms = 0;

    for (
      let channel = 0;
      channel &amp;lt; buffer.numberOfChannels;
      channel += 1
    ) {
      const data = buffer.getChannelData(channel);
      const step = Math.max(
        1,
        Math.floor(data.length / 40000)
      );

      let sum = 0;
      let count = 0;

      for (let i = 0; i &amp;lt; data.length; i += step) {
        sum += data[i] * data[i];
        count += 1;
      }

      if (count &amp;gt; 0) {
        peakRms = Math.max(
          peakRms,
          Math.sqrt(sum / count)
        );
      }
    }

    return peakRms &amp;lt; rmsThreshold;
  } finally {
    await context.close();
  }
};
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;For long recordings, it samples at most roughly 40,000 points per channel rather than inspecting every frame.&lt;/p&gt;

&lt;p&gt;The threshold of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.008&lt;/code&gt; isn’t a universal definition of silence, but is a guard against accepting the near-empty files produced when the wrong capture source is selected. Very quiet intentional audio could fall below it, although that’s not going to happen for this use case.&lt;/p&gt;

&lt;p&gt;If decoding isn’t available or fails, the implementation falls back to checking whether the Blob is suspiciously small. Less accurate, but still more useful than blindly accepting everything.&lt;/p&gt;

&lt;h2 id=&quot;previewing-and-downloading-takes&quot;&gt;Previewing and downloading takes&lt;/h2&gt;

&lt;p&gt;Once a recording passes the silence check, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URL.createObjectURL()&lt;/code&gt; gives me a local URL for it:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;const url = URL.createObjectURL(result.blob);

const recording = {
  id: `rec-${Date.now()}-${recordingSeq++}`,
  blob: result.blob,
  url,
  mimeType: result.mimeType,
  createdAt: Date.now(),
};

setRecordings((previous) =&amp;gt; [recording, ...previous]);
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The same URL powers an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; preview and the download link. Nothing needs to be uploaded to a server. Blob URLs do need a little tidying up. The app revokes each URL when I discard a take and revokes anything remaining when the React component unmounts:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;URL.revokeObjectURL(recording.url);
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The hook also stops every media track, cancels active speech and discards unfinished recordings during cleanup.&lt;/p&gt;

&lt;p&gt;The feature is primarily aimed at desktop Chromium browsers. Screen-audio support and sharing interfaces vary between browsers and operating systems, so I wouldn’t present this as a universal browser recording API. It’s a focused workaround for a focused problem.&lt;/p&gt;

&lt;p&gt;It does solve my problem, though. I can now prepare the line, choose the voice, adjust its speed and pitch, and download the chosen one. . . all without reaching for my phone.&lt;/p&gt;

&lt;p&gt;Getting the recording into Keynote is still a pain. If only it supported webm 🤦‍♂️&lt;/p&gt;

&lt;p&gt;Still. Let’s solve one hotel-room problem at a time.&lt;/p&gt;
</description>
        <pubDate>Sat, 08 Aug 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/recording-web-speech-synthesis/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/recording-web-speech-synthesis/</guid>
      </item>
    
      <item>
        <title>A quick guide to c# expressions in .net MAUI.</title>
        <description>&lt;p&gt;&lt;em&gt;This post is part of the &lt;a href=&quot;https://goforgoldman.com/posts/mauiuijuly-26/&quot;&gt;MAUI UI July&lt;/a&gt; community series of blog posts and videos, hosted by Matt Goldman. Take a look the other posts in this series!&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I’ve written more Boolean converters than I’d care to admit. Not complicated ones, either. They work, but a convertor is a lot of ceremony for something so small.&lt;/p&gt;

&lt;p&gt;C# expressions are coming to .NET MAUI in .NET 11, and they’re designed to remove a bunch of this sort of boilerplate. They let you write small C# expressions directly inside XAML attributes, which means conditions, calculations, formatting and simple event handling can live closer to the controls that use them.&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;VerticalStackLayout&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;IsVisible=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{!IsLoading}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;Button&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Save&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsEnabled=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{IsValid &amp;amp;&amp;amp; !IsSaving}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{$&apos;{FirstName} {LastName}&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This is a fairly substantial change to the way we can work with XAML. It doesn’t mean view models, converters, commands or normal bindings are going away, and it certainly doesn’t mean every bit of application logic should now be squeezed into an XML attribute. What it does mean is that a lot of the tiny pieces of supporting code we write around XAML may no longer be necessary.&lt;/p&gt;

&lt;h2 id=&quot;what-are-c-expressions&quot;&gt;What are C# expressions?&lt;/h2&gt;

&lt;p&gt;C# expressions let you use C# syntax inside XAML attributes. That includes property access, Boolean conditions, arithmetic, string interpolation, null-coalescing, ternary expressions, method calls and small event-handler lambdas.&lt;/p&gt;

&lt;p&gt;The feature is being introduced as part of .NET MAUI in .NET 11 and is still experimental at the time of writing. Some of the syntax, behaviour and limitations may change before the final release, so this probably isn’t the moment to enthusiastically rewrite half a production application on a Friday afternoon.&lt;/p&gt;

&lt;p&gt;The interesting part is that these expressions aren’t simply strings being interpreted at runtime. They’re understood by the .NET MAUI XAML source generator, which can inspect an expression, work out which types and properties it uses, and generate the required C# or binding code at build time. That means that they’re SourceGen only, and not available if using XamlC or Runtime inflation.&lt;/p&gt;

&lt;p&gt;That makes this more than shorter syntax. It gives the compiler a much better understanding of what the XAML is trying to do. It gives you less code to grep.&lt;/p&gt;

&lt;h2 id=&quot;why-is-this-happening&quot;&gt;Why is this happening?&lt;/h2&gt;

&lt;p&gt;XAML is very good at describing the structure of a user interface. It becomes less elegant when you need to add a small amount of presentation logic, particularly when that logic is too simple to deserve its own class or view-model property.&lt;/p&gt;

&lt;p&gt;Suppose you have an activity indicator that should be visible while data is loading, and some page content that should be visible when it isn’t. A fairly typical approach looks like this:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;ContentPage.Resources&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;toolkit:InvertedBoolConverter&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;x:Key=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;InvertedBoolConverter&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/ContentPage.Resources&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;ActivityIndicator&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsRunning=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Binding IsLoading}&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsVisible=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Binding IsLoading}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;VerticalStackLayout&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsVisible=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Binding IsLoading,
        Converter={StaticResource InvertedBoolConverter}}&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;

    &lt;span class=&quot;c&quot;&gt;&amp;lt;!-- Page content --&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;/VerticalStackLayout&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;There’s nothing especially wrong with this. Converters are useful, particularly when transformation logic is shared across several views. In this case, though, we’ve introduced a converter, registered it as a resource and referenced it from the binding simply to invert a Boolean.&lt;/p&gt;

&lt;p&gt;With C# expressions, the same thing becomes:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;ActivityIndicator&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsRunning=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{IsLoading}&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsVisible=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{IsLoading}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;VerticalStackLayout&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;IsVisible=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{!IsLoading}&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;

    &lt;span class=&quot;c&quot;&gt;&amp;lt;!-- Page content --&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;/VerticalStackLayout&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The new version is shorter, but that isn’t the only benefit. The condition is right next to the element that uses it, and the intent is obvious without jumping around the project looking for a converter.&lt;/p&gt;

&lt;p&gt;A tiny bit of logic stays tiny.&lt;/p&gt;

&lt;h2 id=&quot;simple-property-bindings&quot;&gt;Simple property bindings&lt;/h2&gt;

&lt;p&gt;The most basic example is binding to a property.&lt;/p&gt;

&lt;h3 id=&quot;before&quot;&gt;Before&lt;/h3&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Binding Username}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Binding User.DisplayName}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;with-c-expressions&quot;&gt;With C# expressions&lt;/h3&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Username}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{User.DisplayName}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;At first glance, this looks like shorthand for removing the word &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Binding&lt;/code&gt;, but the source generator is doing more than that. When the page has an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;x:DataType&lt;/code&gt;, the expression can be checked against the binding context at build time.&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;ContentPage&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;x:Class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;MyApp.ProfilePage&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;x:DataType=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;viewModels:ProfileViewModel&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;

    &lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Username}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;/ContentPage&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;If &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Username&lt;/code&gt; doesn’t exist on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ProfileViewModel&lt;/code&gt;, the tooling has enough information to complain about it. That’s rather more useful than discovering a typo after navigating to the page and wondering why the label is empty.&lt;/p&gt;

&lt;p&gt;Writable properties can still be used with controls that support two-way binding:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Entry&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Username}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This isn’t throwing away data binding. It’s giving the compiler a more direct and strongly typed way to describe it.&lt;/p&gt;

&lt;h2 id=&quot;boolean-expressions&quot;&gt;Boolean expressions&lt;/h2&gt;

&lt;p&gt;Boolean expressions are probably where many developers will see the quickest win. Applications tend to collect converters and calculated properties for conditions such as “invert this value”, “check whether both values are true” or “show this control when a count is greater than zero”.&lt;/p&gt;

&lt;p&gt;A view model might currently expose another property:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;bool&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CanSave&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;IsValid&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IsSaving&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The XAML then binds to it:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Button&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Save&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsEnabled=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Binding CanSave}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This works, but the view model must also raise a property-change notification for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CanSave&lt;/code&gt; whenever either &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IsValid&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IsSaving&lt;/code&gt; changes. That is easy to forget and can produce the sort of bug where the state is correct but the button remains stubbornly disabled.&lt;/p&gt;

&lt;p&gt;Another option is a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MultiBinding&lt;/code&gt; and converter:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Button&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Save&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;Button.IsEnabled&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;&amp;lt;MultiBinding&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Converter=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{StaticResource CanSaveConverter}&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
            &lt;span class=&quot;nt&quot;&gt;&amp;lt;Binding&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Path=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;IsValid&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class=&quot;nt&quot;&gt;&amp;lt;Binding&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Path=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;IsSaving&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;&amp;lt;/MultiBinding&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;/Button.IsEnabled&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/Button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That is a lot of XAML for a condition that is perfectly readable in one line.&lt;/p&gt;

&lt;h3 id=&quot;with-c-expressions-1&quot;&gt;With C# expressions&lt;/h3&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Button&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Save&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsEnabled=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{IsValid &amp;amp;&amp;amp; !IsSaving}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Other conditions are equally direct:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;IsVisible=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{ItemCount &amp;gt; 0}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;Button&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsEnabled=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{HasAcceptedTerms &amp;amp;&amp;amp; IsFormValid}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;VerticalStackLayout&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsVisible=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{IsSignedIn || IsGuestAccessEnabled}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This is the sort of thing C# expressions are particularly good at. The logic is small, local to the view and understandable without extra context.&lt;/p&gt;

&lt;p&gt;Word-based operators are also being explored to avoid some of the awkwardness caused by XML escaping:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Button&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsEnabled=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{HasAcceptedTerms AND IsFormValid}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The available aliases include forms such as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AND&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OR&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LT&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GT&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LTE&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GTE&lt;/code&gt;. Whether you prefer those or the normal C# operators will probably come down to readability and how much XML complains about what you’re trying to type.&lt;/p&gt;

&lt;h2 id=&quot;calculated-values&quot;&gt;Calculated values&lt;/h2&gt;

&lt;p&gt;Calculated properties are another common piece of view-model furniture. Suppose you have a price and quantity and want to show the total.&lt;/p&gt;

&lt;h3 id=&quot;before-1&quot;&gt;Before&lt;/h3&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;decimal&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Price&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Quantity&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;decimal&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Total&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Price&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Quantity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Binding Total}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Again, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Total&lt;/code&gt; needs to raise a change notification whenever &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Price&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Quantity&lt;/code&gt; changes. Without that notification, the calculation may be correct in the view model while the UI continues showing an old value.&lt;/p&gt;

&lt;p&gt;With a C# expression, the calculation can be written directly in the XAML:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Price * Quantity}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The source generator can inspect the expression and work out which values it depends on. Because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Price * Quantity&lt;/code&gt; isn’t something that can be assigned back to the view model, the generated binding is one-way, which is exactly what you would expect.&lt;/p&gt;

&lt;p&gt;This works well for small presentation calculations. It doesn’t mean your pricing rules, discount logic and tax calculations should now live in a forty-line XAML expression. If the calculation has business meaning, is reused elsewhere or needs proper testing, keep it in C# and give it a useful name.&lt;/p&gt;

&lt;h2 id=&quot;string-interpolation&quot;&gt;String interpolation&lt;/h2&gt;

&lt;p&gt;Formatting text in XAML has always been possible, but it isn’t always pleasant. You might use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;StringFormat&lt;/code&gt;, a calculated property or a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FormattedString&lt;/code&gt; full of spans.&lt;/p&gt;

&lt;p&gt;For example, displaying a full name might involve another property.&lt;/p&gt;

&lt;h3 id=&quot;before-2&quot;&gt;Before&lt;/h3&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;FullName&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;$&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FirstName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LastName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Binding FullName}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;with-c-expressions-2&quot;&gt;With C# expressions&lt;/h3&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{$&apos;{FirstName} {LastName}&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The single quotes are used because the XAML attribute itself is already wrapped in double quotes. You can use the same approach for other bits of display text:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{$&apos;Hello, {FirstName}&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{$&apos;{Quantity} × {ProductName}&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{$&apos;Total: {Price:C2}&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Dates can be formatted in the same way:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{$&apos;Arriving {ArrivalDate:dddd, d MMMM}&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This should be particularly useful when the formatted text exists for one specific view. If several pages need the same formatting, or the formatting is part of the application’s behaviour rather than its presentation, a named property or formatter is probably still the better home.&lt;/p&gt;

&lt;h2 id=&quot;ternary-expressions&quot;&gt;Ternary expressions&lt;/h2&gt;

&lt;p&gt;Sometimes you need to choose between two values. A common way of doing this is to add another property to the view model.&lt;/p&gt;

&lt;h3 id=&quot;before-3&quot;&gt;Before&lt;/h3&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AccountTypeText&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;IsPremium&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Premium account&quot;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Standard account&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Binding AccountTypeText}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;with-c-expressions-3&quot;&gt;With C# expressions&lt;/h3&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{IsPremium ? &apos;Premium account&apos; : &apos;Standard account&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You can also combine a ternary with interpolation:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{ItemCount == 1 ? &apos;1 item&apos; : $&apos;{ItemCount} items&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This is useful up to a point. One small ternary expression is easy to understand, but three nested ternaries and a method call will quickly make the XAML harder to follow.&lt;/p&gt;

&lt;p&gt;At that point, give the condition a name and move it back into C#.&lt;/p&gt;

&lt;h2 id=&quot;null-handling&quot;&gt;Null handling&lt;/h2&gt;

&lt;p&gt;Null fallback values are another good fit for expressions.&lt;/p&gt;

&lt;h3 id=&quot;before-4&quot;&gt;Before&lt;/h3&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;DisplayName&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Name&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Unknown user&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Binding DisplayName}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;with-c-expressions-4&quot;&gt;With C# expressions&lt;/h3&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Name ?? &apos;Unknown user&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You can also use null-aware property access:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{User?.DisplayName ?? &apos;Guest&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This is useful when the fallback is specific to a particular element. If the application has a defined rule for how user names should be displayed everywhere, put that rule somewhere reusable rather than copying the same expression across six pages.&lt;/p&gt;

&lt;h2 id=&quot;method-calls&quot;&gt;Method calls&lt;/h2&gt;

&lt;p&gt;Expressions can call methods:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Name.ToUpperInvariant()}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;They can also call helper methods:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{GetFormattedDate()}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Static methods are supported too:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;ProgressBar&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Progress=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Math.Min(Progress, 1)}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This comes with an important caveat: not every expression is automatically reactive. An expression generated as a binding can update when its dependencies raise property-change notifications, but a method called directly from the page may be evaluated once when the view is created.&lt;/p&gt;

&lt;p&gt;If you write this:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{GetCurrentStatus()}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;you shouldn’t assume it will rerun whenever some unrelated property changes. The expression needs a clear set of observable dependencies for the generated binding to know when it should update.&lt;/p&gt;

&lt;p&gt;This is one of the areas worth testing carefully while the feature is still in preview.&lt;/p&gt;

&lt;h2 id=&quot;inline-event-handlers&quot;&gt;Inline event handlers&lt;/h2&gt;

&lt;p&gt;C# expressions can also be used to handle events. Imagine a button that increments a counter.&lt;/p&gt;

&lt;h3 id=&quot;before-5&quot;&gt;Before&lt;/h3&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Button&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Add&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Clicked=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;OnAddClicked&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;OnAddClicked&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;object&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sender&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;EventArgs&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;++;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;with-c-expressions-5&quot;&gt;With C# expressions&lt;/h3&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Button&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Add&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Clicked=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{(sender, args) =&amp;gt; Count++}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You can call a method in the same way:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Button&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Reset&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Clicked=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{(sender, args) =&amp;gt; ResetForm()}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This is useful for genuinely tiny synchronous actions. It becomes less useful when the event handler contains validation, logging, navigation, cancellation, error handling and three service calls. At that point, give it a name and put it somewhere sensible.&lt;/p&gt;

&lt;p&gt;Async lambdas aren’t currently supported, so asynchronous event handlers still need a normal method or command:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Button&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Save&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Clicked=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;OnSaveClicked&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;OnSaveClicked&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;object&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sender&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;EventArgs&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;viewModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;SaveAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That limitation may feel inconvenient, but it also stops the XAML from becoming a hiding place for sizeable chunks of asynchronous application logic. Probably not a bad thing.&lt;/p&gt;

&lt;h2 id=&quot;existing-xaml-markup-still-works&quot;&gt;Existing XAML markup still works&lt;/h2&gt;

&lt;p&gt;C# expressions are being added alongside the XAML features that already exist. Normal bindings, static resources and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;x:Static&lt;/code&gt; all continue to work:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{Binding Name}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{StaticResource PageTitle}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{x:Static local:AppConstants.AppName}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The source generator needs to work out whether the contents of the braces are a markup extension or a C# expression. Where something is ambiguous, you can be explicit:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;&amp;lt;!-- Explicit expression --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{= SomeValue}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;&amp;lt;!-- Resolve against the page --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{this.Title}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;&amp;lt;!-- Resolve against the binding context --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{.Title}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The last two are useful when the page and its binding context contain properties with the same name. It’s one of those problems you may not have until you do, at which point you’ll be pleased the explicit syntax exists.&lt;/p&gt;

&lt;h2 id=&quot;what-could-this-replace&quot;&gt;What could this replace?&lt;/h2&gt;

&lt;p&gt;C# expressions may remove the need for quite a few small pieces of supporting code, including inverted Boolean converters, simple comparison converters, some multi-value converters, presentation-only calculated properties, basic formatting properties, straightforward triggers and tiny synchronous event handlers.&lt;/p&gt;

&lt;p&gt;A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MultiBinding&lt;/code&gt; such as this:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;Label.IsVisible&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;&amp;lt;MultiBinding&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;Converter=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{StaticResource AllTrueConverter}&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;

            &lt;span class=&quot;nt&quot;&gt;&amp;lt;Binding&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Path=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;IsLoggedIn&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class=&quot;nt&quot;&gt;&amp;lt;Binding&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;Path=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;HasSubscription&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

        &lt;span class=&quot;nt&quot;&gt;&amp;lt;/MultiBinding&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;/Label.IsVisible&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/Label&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;can become:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsVisible=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{IsLoggedIn &amp;amp;&amp;amp; HasSubscription}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That is fewer lines, but the more useful change is that the condition is visible immediately. You don’t need to inspect a converter to discover whether &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AllTrueConverter&lt;/code&gt; really means all values must be true, whether null counts as false or whether it does something surprising because someone changed it four years ago.&lt;/p&gt;

&lt;h2 id=&quot;what-shouldnt-go-into-an-expression&quot;&gt;What shouldn’t go into an expression?&lt;/h2&gt;

&lt;p&gt;Just because C# can now be written inside XAML doesn’t mean every bit of application logic should move there. I’d avoid using expressions for business rules, network calls, database operations, complex state changes, reusable calculations, anything that needs focused unit testing or anything that requires a comment to explain what it does.&lt;/p&gt;

&lt;p&gt;You could write something like this:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsVisible=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{
        CurrentUser != null &amp;amp;&amp;amp;
        CurrentUser.Subscription != null &amp;amp;&amp;amp;
        CurrentUser.Subscription.ExpiresAt &amp;gt; DateTime.Now &amp;amp;&amp;amp;
        !CurrentUser.IsSuspended
    }&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;But a named property is much easier to understand:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;IsVisible=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{CanAccessPremiumFeatures}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The second version tells me what the condition means. The first tells me how it is calculated and asks me to work out the meaning myself.&lt;/p&gt;

&lt;p&gt;That is the line I’d use when deciding whether an expression belongs in XAML: use an expression when it makes the XAML more obvious, and use a named property or method when it makes the intention more obvious.&lt;/p&gt;

&lt;h2 id=&quot;a-complete-example&quot;&gt;A complete example&lt;/h2&gt;

&lt;p&gt;Here’s a small page using several of the new expression types together:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;ContentPage&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;x:Class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;ExpressionDemo.MainPage&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;x:DataType=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;viewModels:MainViewModel&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;

    &lt;span class=&quot;nt&quot;&gt;&amp;lt;VerticalStackLayout&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;Padding=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;24&quot;&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;Spacing=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;16&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;

        &lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;FontSize=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;24&quot;&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{$&apos;Welcome, {FirstName}&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

        &lt;span class=&quot;nt&quot;&gt;&amp;lt;ActivityIndicator&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;IsRunning=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{IsLoading}&quot;&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;IsVisible=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{IsLoading}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

        &lt;span class=&quot;nt&quot;&gt;&amp;lt;VerticalStackLayout&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;IsVisible=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{!IsLoading}&quot;&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;Spacing=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;12&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;

            &lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt;
                &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{$&apos;{Quantity} × {ProductName}&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

            &lt;span class=&quot;nt&quot;&gt;&amp;lt;Label&lt;/span&gt;
                &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{$&apos;Total: {Price * Quantity:C2}&apos;}&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

            &lt;span class=&quot;nt&quot;&gt;&amp;lt;Button&lt;/span&gt;
                &lt;span class=&quot;na&quot;&gt;Text=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Place order&quot;&lt;/span&gt;
                &lt;span class=&quot;na&quot;&gt;IsEnabled=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{
                    Quantity &amp;gt; 0 &amp;amp;&amp;amp;
                    HasAcceptedTerms &amp;amp;&amp;amp;
                    !IsSubmitting
                }&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;/&amp;gt;&lt;/span&gt;

        &lt;span class=&quot;nt&quot;&gt;&amp;lt;/VerticalStackLayout&amp;gt;&lt;/span&gt;

    &lt;span class=&quot;nt&quot;&gt;&amp;lt;/VerticalStackLayout&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;/ContentPage&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;There are no converters here, no calculated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Total&lt;/code&gt; property and no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CanPlaceOrder&lt;/code&gt; property that needs change notifications whenever three other values change.&lt;/p&gt;

&lt;p&gt;The important question isn’t whether this version contains fewer lines. It’s whether someone opening the page can understand what it does.&lt;/p&gt;

&lt;p&gt;In this case, I think they probably can.&lt;/p&gt;

&lt;h2 id=&quot;when-can-i-try-it&quot;&gt;When can I try it?&lt;/h2&gt;

&lt;p&gt;C# expressions are being developed for .NET MAUI in .NET 11. .NET 11 is scheduled for release in November 2026, but the feature can be tried using the .NET 11 preview SDK and the matching .NET MAUI workload.&lt;/p&gt;

&lt;p&gt;It’s still experimental, so expect rough edges, incomplete tooling and behaviour that may shift between preview releases. I wouldn’t start converting a production application wholesale yet.&lt;/p&gt;

&lt;p&gt;A better approach is to create a small test project or pick one isolated page containing a few obvious candidates: an inverted Boolean converter, a basic &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MultiBinding&lt;/code&gt;, a display-only calculated property, a formatted string or a one-line synchronous event handler.&lt;/p&gt;

&lt;p&gt;Replace them one at a time and see what the resulting XAML feels like. Check whether it becomes easier to read, whether the expression updates when you expect it to, whether Hot Reload behaves and whether the compiler errors point you towards the problem when something goes wrong.&lt;/p&gt;

&lt;p&gt;Those are the useful questions during a preview.&lt;/p&gt;

&lt;h2 id=&quot;give-it-a-try&quot;&gt;Give it a try&lt;/h2&gt;

&lt;p&gt;C# expressions won’t replace MVVM, converters, commands, view models or code-behind, and they shouldn’t. What they offer is a way to remove some of the faff around small bits of presentation logic.&lt;/p&gt;

&lt;p&gt;That could mean fewer converters, fewer properties that exist purely for one label and less hopping between files to understand a simple condition.&lt;/p&gt;

&lt;p&gt;Try them in a small page and see which bits of boilerplate disappear. More importantly, notice which bits are still clearer with a proper name in the view model.&lt;/p&gt;

&lt;p&gt;Then tell the .NET MAUI team what worked, what didn’t and where the tooling got confused. Preview features improve when people use them for real things rather than admiring them from a release note.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/dotnet/maui/blob/main/docs/specs/XamlCSharpExpressions.md&quot;&gt;Read the spec&lt;/a&gt;&lt;/p&gt;
</description>
        <pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/c-sharp-expressions-in-xaml/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/c-sharp-expressions-in-xaml/</guid>
      </item>
    
      <item>
        <title>User Needs Mapping by Rich Allen</title>
        <description>&lt;p&gt;I took some time out of the office today, releshing the opportunity to read a good book in the sunshine.&lt;/p&gt;

&lt;p&gt;And it’s almost as good as the view!&lt;/p&gt;

&lt;p&gt;User needs are difficult to pin down, whether the individual is internal or external to the business. Sometimes they’re masked by complexity. Sometimes they’re hidden behind user pains. And sometimes. . . just sometimes. . . no one actually knows what they need. . . only what they want.&lt;/p&gt;

&lt;p&gt;All combined, they result in unaligned goals across a team/org, ill defined boundaries and responsibilities, and confusion between internal needs and the actual needs of the end user. (Developer experience is important. Honest boss!)&lt;/p&gt;

&lt;p&gt;Thankfully, Rich Allen is here to save the day!&lt;/p&gt;

&lt;p&gt;In User Needs Mapping, Rich draws from multiple Agile-esque processes to distill the core of User Needs Mapping. It gives you the language to talk about the issues to be solved with clients and the team around you, and breaks down the process step by step with really good illustrations to support the text.&lt;/p&gt;

&lt;p&gt;My favourite thing about this book is the examples. They’re as deep as they need to be, exceptionally well written, and not the usual. After reading a few books of this ilk, it’s nice to not see the same examples being regurgitated in an effort to draw new insights.&lt;/p&gt;

&lt;p&gt;Highly recommended 👍&lt;/p&gt;

&lt;p&gt;If User Needs Mapping is something you’ve been meaning to look deeper at, pick up a copy of this book and learn EVERYTHING you need to know to do the job.&lt;/p&gt;
</description>
        <pubDate>Mon, 20 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/user-needs-mapping-rich-allen/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/user-needs-mapping-rich-allen/</guid>
      </item>
    
      <item>
        <title>Making static Astro pages editable with Decap</title>
        <description>&lt;p&gt;I recently added Decap CMS to an Astro site that already had an ordinary collection of articles. That bit was straightforward enough: articles all share roughly the same shape, so they fit neatly into a folder-based content collection.&lt;/p&gt;

&lt;p&gt;Then came the static pages.&lt;/p&gt;

&lt;p&gt;The homepage had a hero and a short introduction. The About page had similar introductory content, plus a list of things the organisation stands for. The copy was hardcoded directly into the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.astro&lt;/code&gt; templates, which meant every wording change needed a developer, a code edit and a deployment.&lt;/p&gt;

&lt;p&gt;Less than ideal for changing a sentence.&lt;/p&gt;

&lt;p&gt;The goal was to make those pages editable through Decap without turning the CMS configuration into a giant generic page builder. I also didn’t want About-specific fields appearing in the homepage editor simply because both happened to be pages.&lt;/p&gt;

&lt;p&gt;The approach I settled on was:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;one Markdown content entry for each static page&lt;/li&gt;
  &lt;li&gt;one Astro content collection for each page type&lt;/li&gt;
  &lt;li&gt;one Decap file collection containing the individual page editors&lt;/li&gt;
  &lt;li&gt;the existing Astro templates left largely intact&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s a small amount of wiring, keeps the types useful and gives editors a clean form containing only the fields they actually need.&lt;/p&gt;

&lt;h2 id=&quot;why-use-a-file-collection&quot;&gt;Why use a file collection?&lt;/h2&gt;

&lt;p&gt;Decap supports both folder collections and file collections.&lt;/p&gt;

&lt;p&gt;Folder collections are a good fit for repeatable content such as articles, products or team members. Each entry has the same fields, and editors can usually create more of them.&lt;/p&gt;

&lt;p&gt;A file collection works differently. Each entry points to a specific file and can define its own set of fields. Decap’s documentation specifically calls out custom landing pages and other uniquely structured files as a good use case.&lt;/p&gt;

&lt;p&gt;That matched what I needed.&lt;/p&gt;

&lt;p&gt;There is only one homepage. I don’t want an editor merrily creating &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;homepage-2.md&lt;/code&gt;, however tempting the sequel might be.&lt;/p&gt;

&lt;p&gt;The About page also has a slightly different content model from the homepage. Keeping it as a separate file definition means its list fields stay in its editor rather than leaking into every other page.&lt;/p&gt;

&lt;p&gt;The resulting structure looked like this:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;src/
├── content/
│   ├── articles/
│   ├── homepage/
│   │   └── index.md
│   └── about/
│       └── index.md
├── content.config.ts
└── pages/
    ├── index.astro
    └── about-us.astro

public/
└── admin/
    └── config.yml
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;1-define-the-astro-content-collections&quot;&gt;1. Define the Astro content collections&lt;/h2&gt;

&lt;p&gt;Astro content collections let you define the expected shape of your content and then query it through APIs such as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;getEntry()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For local Markdown files, Astro provides a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;glob()&lt;/code&gt; loader. You give it a base directory and a pattern, then add a schema to validate the frontmatter. The schema also supplies TypeScript types and editor autocomplete when you use the content later.&lt;/p&gt;

&lt;p&gt;Here’s a deliberately small example:&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// src/content.config.ts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;defineCollection&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;astro:content&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;glob&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;astro/loaders&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;astro/zod&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;homepage&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;defineCollection&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;loader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;glob&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;pattern&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;index.md&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;./src/content/homepage&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}),&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;pageTitle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;heroHeading&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;introduction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;about&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;defineCollection&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;loader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;glob&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;pattern&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;index.md&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;./src/content/about&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}),&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;pageTitle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;heroHeading&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;standForItems&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;array&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;min&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;collections&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;homepage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;about&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;There are two collections even though each contains only one entry.&lt;/p&gt;

&lt;p&gt;That might look slightly over-organised at first, but it gives each page its own schema. The homepage doesn’t need to know what a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;standForItem&lt;/code&gt; is, and the About page can require at least one of them.&lt;/p&gt;

&lt;p&gt;You could create one enormous &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pages&lt;/code&gt; schema with a pile of optional fields:&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;standForItems&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;array&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;optional&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Do that a few more times, though, and you end up with a schema that doesn’t properly describe any of your pages. Everything is technically allowed everywhere, and the types stop protecting you from much.&lt;/p&gt;

&lt;p&gt;Separate schemas are a little more verbose but much clearer.&lt;/p&gt;

&lt;p&gt;One small version-related detail: current Astro documentation imports Zod from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;astro/zod&lt;/code&gt;. Older projects and examples may use a different import, so follow the convention used by the Astro version installed in your project.&lt;/p&gt;

&lt;h2 id=&quot;2-move-the-page-copy-into-markdown&quot;&gt;2. Move the page copy into Markdown&lt;/h2&gt;

&lt;p&gt;Next, create the actual content entries.&lt;/p&gt;

&lt;p&gt;For the homepage:&lt;/p&gt;

&lt;div class=&quot;language-md highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nn&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;pageTitle&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Cornwall Self-Catering Collective&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;heroHeading&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;One voice for self-catering in Cornwall&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;introduction&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;We represent professional self-catering agencies across Cornwall.&lt;/span&gt;
&lt;span class=&quot;nn&quot;&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Save that as:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;src/content/homepage/index.md
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then create the About page:&lt;/p&gt;

&lt;div class=&quot;language-md highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nn&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;pageTitle&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;About us&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;heroHeading&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;A collective voice for responsible self-catering&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;standForItems&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Professional standards&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Responsible tourism&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Strong local communities&lt;/span&gt;
&lt;span class=&quot;nn&quot;&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Save that as:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;src/content/about/index.md
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The filenames matter because Astro derives the entry ID from the filename when using the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;glob()&lt;/code&gt; loader. In this case, both entry IDs are &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That becomes important when we load them in the page templates.&lt;/p&gt;

&lt;p&gt;The frontmatter names must also match the Zod schemas exactly. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;heroHeading&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hero_heading&lt;/code&gt; may look like close relatives to a human, but they are not the same field to Astro.&lt;/p&gt;

&lt;h2 id=&quot;3-add-the-pages-to-decap-cms&quot;&gt;3. Add the pages to Decap CMS&lt;/h2&gt;

&lt;p&gt;Decap’s main configuration normally lives in the admin directory, commonly as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;public/admin/config.yml&lt;/code&gt; in an Astro project. Collections define what appears in the editor and which files Decap updates.&lt;/p&gt;

&lt;p&gt;Add a file-based collection for the static pages:&lt;/p&gt;

&lt;div class=&quot;language-yml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;collections&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;pages&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Pages&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;files&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Homepage&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;homepage&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;src/content/homepage/index.md&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;fields&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;Page title&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;pageTitle&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;widget&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
          &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;Hero heading&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;heroHeading&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;widget&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
          &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;Introduction&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;introduction&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;widget&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;

      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;About us&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;about&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;src/content/about/index.md&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;fields&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;Page title&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;pageTitle&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;widget&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
          &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;Hero heading&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;heroHeading&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;widget&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
          &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;What we stand for&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;standForItems&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;widget&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;list&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;field&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
              &lt;span class=&quot;na&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Item&lt;/span&gt;
              &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;item&lt;/span&gt;
              &lt;span class=&quot;na&quot;&gt;widget&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;file&lt;/code&gt; value is relative to the root of the repository, not the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;admin&lt;/code&gt; directory.&lt;/p&gt;

&lt;p&gt;Each page gets its own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fields&lt;/code&gt; block. That’s the useful bit: the homepage editor contains only homepage fields, while the About editor gets its repeatable list.&lt;/p&gt;

&lt;p&gt;Decap widgets control both the editing interface and the type of data that gets written. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;string&lt;/code&gt; widget is suited to shorter values, while &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text&lt;/code&gt; provides a multiline field.&lt;/p&gt;

&lt;p&gt;For &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;standForItems&lt;/code&gt;, I used a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list&lt;/code&gt; widget with one repeated string field. This lets the editor add, remove and reorder items without editing YAML by hand.&lt;/p&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt; values in Decap must match the names in your Markdown frontmatter and your Astro schema:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Decap config
    ↓
Markdown frontmatter
    ↓
Astro schema
    ↓
Astro template
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A mismatch anywhere along that chain will either produce a validation error or leave you wondering why your newly edited heading has disappeared into the void.&lt;/p&gt;

&lt;h2 id=&quot;4-load-the-content-in-the-astro-page&quot;&gt;4. Load the content in the Astro page&lt;/h2&gt;

&lt;p&gt;The homepage previously contained hardcoded content:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-astro&quot;&gt;&amp;lt;h1&amp;gt;One voice for self-catering in Cornwall&amp;lt;/h1&amp;gt;

&amp;lt;p&amp;gt;
  We represent professional self-catering agencies across Cornwall.
&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Replace the hardcoded values with a content entry.&lt;/p&gt;

&lt;p&gt;In &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;src/pages/index.astro&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-astro&quot;&gt;---
import { getEntry } from &quot;astro:content&quot;;
import BaseLayout from &quot;../layouts/BaseLayout.astro&quot;;

const homepage = await getEntry(&quot;homepage&quot;, &quot;index&quot;);

if (!homepage) {
  throw new Error(
    &quot;Missing homepage content at src/content/homepage/index.md&quot;
  );
}

const { data } = homepage;
---

&amp;lt;BaseLayout title={data.pageTitle}&amp;gt;
  &amp;lt;h1&amp;gt;{data.heroHeading}&amp;lt;/h1&amp;gt;
  &amp;lt;p&amp;gt;{data.introduction}&amp;lt;/p&amp;gt;
&amp;lt;/BaseLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;getEntry()&lt;/code&gt; takes the collection name followed by the entry ID. Astro exposes this function specifically for retrieving a known item from a build-time content collection.&lt;/p&gt;

&lt;p&gt;Because the file is called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index.md&lt;/code&gt;, the entry ID is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nf&quot;&gt;getEntry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;homepage&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;index&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Not:&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nf&quot;&gt;getEntry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;homepage&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;homepage&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I added an explicit error when the entry is missing. You could use optional chaining and quietly render nothing, but that tends to turn a straightforward content problem into a slightly tedious layout investigation.&lt;/p&gt;

&lt;p&gt;Failing with the exact missing path is much more useful.&lt;/p&gt;

&lt;h2 id=&quot;5-do-the-same-for-the-about-page&quot;&gt;5. Do the same for the About page&lt;/h2&gt;

&lt;p&gt;The About page follows the same pattern:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-astro&quot;&gt;---
import { getEntry } from &quot;astro:content&quot;;
import BaseLayout from &quot;../layouts/BaseLayout.astro&quot;;

const about = await getEntry(&quot;about&quot;, &quot;index&quot;);

if (!about) {
  throw new Error(
    &quot;Missing about content at src/content/about/index.md&quot;
  );
}

const { data } = about;
---

&amp;lt;BaseLayout title={data.pageTitle}&amp;gt;
  &amp;lt;h1&amp;gt;{data.heroHeading}&amp;lt;/h1&amp;gt;

  &amp;lt;ul&amp;gt;
    {data.standForItems.map((item) =&amp;gt; &amp;lt;li&amp;gt;{item}&amp;lt;/li&amp;gt;)}
  &amp;lt;/ul&amp;gt;
&amp;lt;/BaseLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The existing HTML structure, classes and components can stay as they are. You are changing the source of the copy, not rebuilding the page.&lt;/p&gt;

&lt;p&gt;That distinction kept this change pleasantly contained. The CSS didn’t care whether a heading came from a string inside the template or from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data.heroHeading&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;6-test-the-full-editing-loop&quot;&gt;6. Test the full editing loop&lt;/h2&gt;

&lt;p&gt;A successful build proves that the schemas and imports are valid, but it doesn’t prove that an editor can actually change the content.&lt;/p&gt;

&lt;p&gt;I checked the entire loop:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Start the Astro development server.&lt;/li&gt;
  &lt;li&gt;Start Decap’s local development setup, if you use one.&lt;/li&gt;
  &lt;li&gt;Open &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/admin/&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Edit the homepage heading and save it.&lt;/li&gt;
  &lt;li&gt;Confirm that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;src/content/homepage/index.md&lt;/code&gt; changed.&lt;/li&gt;
  &lt;li&gt;Refresh the homepage and check the new text.&lt;/li&gt;
  &lt;li&gt;Edit the About page list.&lt;/li&gt;
  &lt;li&gt;Confirm that the list renders correctly.&lt;/li&gt;
  &lt;li&gt;Run a production build.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In this project, the commands were:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;pnpm dev
pnpm cms
pnpm build
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Your CMS command may differ depending on how local Decap development has been configured.&lt;/p&gt;

&lt;p&gt;The important test is not merely that the CMS says “saved”. Check the underlying Markdown file. Decap writes content into the repository through its configured backend; it is not the system that renders the final website. Astro still needs to read that content and build the page.&lt;/p&gt;

&lt;p&gt;A final production build is worth doing because Astro’s schema validation catches missing fields and incorrect content types before the site is deployed.&lt;/p&gt;

&lt;p&gt;In my case, the build passed after the templates and collections were wired together. Happy days.&lt;/p&gt;

&lt;h2 id=&quot;adding-another-static-page&quot;&gt;Adding another static page&lt;/h2&gt;

&lt;p&gt;Once the pattern is in place, adding another page is mostly repetition.&lt;/p&gt;

&lt;p&gt;For a contact page, for example:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Create &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;src/content/contact/index.md&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Add a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;contact&lt;/code&gt; collection to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;src/content.config.ts&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Add a Contact entry under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;collections.pages.files&lt;/code&gt; in Decap.&lt;/li&gt;
  &lt;li&gt;Load it with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;getEntry(&quot;contact&quot;, &quot;index&quot;)&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Replace the hardcoded template values with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data&lt;/code&gt; properties.&lt;/li&gt;
  &lt;li&gt;Run the CMS and production build checks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It isn’t a fully dynamic page builder, and that is quite deliberate.&lt;/p&gt;

&lt;p&gt;Editors can change the content you have chosen to expose, while the Astro templates retain control over the markup, layout and design. For this site, that was a much better fit than allowing arbitrary sections to be assembled in the CMS.&lt;/p&gt;

&lt;h2 id=&quot;a-few-easy-mistakes&quot;&gt;A few easy mistakes&lt;/h2&gt;

&lt;h3 id=&quot;using-different-field-names&quot;&gt;Using different field names&lt;/h3&gt;

&lt;p&gt;This is the most likely one.&lt;/p&gt;

&lt;p&gt;These must all agree:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;config.yml:          heroHeading
Markdown:            heroHeading
content.config.ts:   heroHeading
Astro template:      data.heroHeading
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Pick a naming convention and stick to it.&lt;/p&gt;

&lt;h3 id=&quot;using-the-wrong-entry-id&quot;&gt;Using the wrong entry ID&lt;/h3&gt;

&lt;p&gt;With an entry stored as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index.md&lt;/code&gt;, the ID generated by the glob loader is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Use:&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;getEntry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;about&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;index&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;forgetting-that-file-entries-must-exist&quot;&gt;Forgetting that file entries must exist&lt;/h3&gt;

&lt;p&gt;A Decap file collection points to known files. Those files need to exist in the configured repository branch before Decap can edit them.&lt;/p&gt;

&lt;p&gt;Create and commit the Markdown files rather than expecting Decap to invent them on first use.&lt;/p&gt;

&lt;h3 id=&quot;making-one-universal-schema-too-early&quot;&gt;Making one universal schema too early&lt;/h3&gt;

&lt;p&gt;A shared schema can make sense when several pages genuinely use the same content model.&lt;/p&gt;

&lt;p&gt;It is less useful when it becomes a bucket of optional properties:&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;heroHeading&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;optional&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;standForItems&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;array&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;optional&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;contactDetails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({}).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;optional&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;mapLocation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;optional&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;At that point the schema is mostly documenting that anything might be there.&lt;/p&gt;

&lt;p&gt;Start with page-specific schemas. Pull shared parts out later when repetition becomes real rather than theoretical.&lt;/p&gt;

&lt;h2 id=&quot;was-it-worth-it&quot;&gt;Was it worth it?&lt;/h2&gt;

&lt;p&gt;This approach adds a few files and a little configuration, but it gives the site a useful middle ground.&lt;/p&gt;

&lt;p&gt;The content team can edit the pages through Decap. Astro still owns the templates and styling. Zod checks that the saved content has the shape the templates expect. Page-specific fields remain page-specific.&lt;/p&gt;

&lt;p&gt;It won’t cover every possible CMS setup, particularly sites that need flexible visual page building or deeply nested content blocks. For a handful of designed static pages, though, a file collection backed by small Astro content collections is simple, typed and easy to extend.&lt;/p&gt;

&lt;p&gt;More importantly, changing a homepage heading no longer requires opening a code editor.. . which was kinda the point.&lt;/p&gt;
</description>
        <pubDate>Wed, 15 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/making-static-pages-editable-with-decap/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/making-static-pages-editable-with-decap/</guid>
      </item>
    
      <item>
        <title>Thank you re:connect conference</title>
        <description>&lt;p&gt;Community conferences are always the best.&lt;/p&gt;

&lt;p&gt;And amongst them, re:connect is right up there.&lt;/p&gt;

&lt;p&gt;Running in Bournemouth every summer, the event is the spearhead of the local tech meetup scene. Hosted at the CityGate centre, right next to the train station, the gathering brought together a mix of people from all corners of the software development discipline from around Europe.&lt;/p&gt;

&lt;p&gt;So what made it a compelling conference?&lt;/p&gt;

&lt;p&gt;There wasn’t a huge lineup. But every speaker brought their a-game, added something unique, and offered an insightful takeaway or two.&lt;/p&gt;

&lt;p&gt;It wasn’t the hugest audience. But every attendee came to learn, chat, to meet their peers and add their input into the collective conversation.&lt;/p&gt;

&lt;p&gt;But that’s true of a lot of events.&lt;/p&gt;

&lt;p&gt;So. . . what was the differentiator, making this one rank towards the top of the pile?&lt;/p&gt;

&lt;p&gt;Honesty.&lt;/p&gt;

&lt;p&gt;It’s as simple as that.&lt;/p&gt;

&lt;p&gt;Reconnect wasn’t trying to be something it’s not. No heirs, no graces, and no pretentiousness. Not trying to make things seem more that they were, in the best way possible. Just a bunch of people coming together to enjoy getting geeky about our common passion whilst learning about topics on the periphery of the day to day.&lt;/p&gt;

&lt;p&gt;If you’re looking for a friendly community to be a part of, or somewhere to drop by and deliver a talk. . . look no further than &lt;a href=&quot;https://reconnectconference.co.uk/&quot;&gt;re:connect conference&lt;/a&gt;, &lt;a href=&quot;https://devdottalk.uk/&quot;&gt;{dev.talk}&lt;/a&gt; and the ecosystem around the pair. Great event, in a fab location.&lt;/p&gt;

&lt;p&gt;Thanks to everyone who came along to listen to me wax lyrical about hip-hop and .NET MAUI 🙌&lt;/p&gt;

&lt;p&gt;Can’t wait for my next visit!&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2026/bournemouth-ice-cream.jpg&quot; alt=&quot;Ice Cream on Bournemouth pier&quot; /&gt;&lt;/p&gt;
</description>
        <pubDate>Fri, 10 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/reconnect-conf/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/reconnect-conf/</guid>
      </item>
    
      <item>
        <title>Entertainment is consumed. Fun is created.</title>
        <description>
</description>
        <pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/entertainment-you-consume-fun-you-make/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/entertainment-you-consume-fun-you-make/</guid>
      </item>
    
      <item>
        <title>One day. . . I'll go straight there.</title>
        <description>&lt;p&gt;See you soon-ish City JS!&lt;/p&gt;
</description>
        <pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/one-day-ill-go-straight-there/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/one-day-ill-go-straight-there/</guid>
      </item>
    
      <item>
        <title>MAUI Day and the stowaway ladybird</title>
        <description>&lt;p&gt;7 Talks + 2 nights away + a stowaway ladybird = another successful MAUI Day!&lt;/p&gt;

&lt;p&gt;With almost 100 attendees in the room, how could it not be?&lt;/p&gt;

&lt;p&gt;The gaggle of .NET MAUI developers had descended from around the globe on central London for a day of cross-platform knowledge sharing. Not only did we learn a whole bunch, but we also got to meet and mingle with the faces behind the avatars that we chat with day in and out.&lt;/p&gt;

&lt;p&gt;Between Syncfusion providing the food, NewDay providing the venue, and the community providing the conversations, the room was in exceptionally high spirits.&lt;/p&gt;

&lt;p&gt;Was it fun?&lt;/p&gt;

&lt;p&gt;It sure was.&lt;/p&gt;

&lt;p&gt;Did it run on time?&lt;/p&gt;

&lt;p&gt;Hell no!&lt;/p&gt;

&lt;p&gt;Will we do it again?&lt;/p&gt;

&lt;p&gt;For sure!&lt;/p&gt;

&lt;p&gt;Where?&lt;/p&gt;

&lt;p&gt;Everywhere!&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2026/maui-day-shriram.jpg&quot; alt=&quot;Photo of Shriram at MAUI Day&quot; title=&quot;Photo of Shriram at MAUI Day&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;

&lt;p&gt;For as long as another group of speakers are willing to assemble to provide an excuse to get together, then we’ve got no excuse not to. Massive thanks to David, Stéphane, Mark, Matt, Shriram, and Clifford for giving the days conversations a focal point, plus allowing the conf to ✅ the 5 continents box.&lt;/p&gt;

&lt;p&gt;Not sure how we’ll reach six!&lt;/p&gt;

&lt;p&gt;In a distributed sector, within a distributed world, opportunities to connect in person and deepen the rapport built on top of hundreds of online chats is important. Both to the individual and the whole. Communities thrive based on the value of conversations, and judging by the buzz in the room last week the hashtag#dotnetMAUI community has never been so alive.&lt;/p&gt;

&lt;p&gt;Just like the ladybird.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2026/maui-day-ladybird.jpg&quot; alt=&quot;The Ladybird packed into a suitcase&quot; title=&quot;The Ladybird packed into a suitcase&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;

&lt;p&gt;She’s part of the colony hibernating in the bedroom back home, although one of the family decided to come on the 580 mile round train trip.&lt;/p&gt;

&lt;p&gt;Thankfully, Premier Inn rooms are hermetically sealed. . . meaning she could fly around the room in the evening without me having to worry about her escaping. Countryside bugs don’t deserve city life, although I’m sure she has some stories to tell the others!&lt;/p&gt;

&lt;p&gt;We both have to be fair.&lt;/p&gt;

&lt;p&gt;Overall, it was another successful trip to the UK capital for the hashtag#MAUIDay crew.&lt;/p&gt;

&lt;p&gt;But multiple attendees on the day gave me feedback that there should be more focus on performance amongst the sessions.&lt;/p&gt;

&lt;p&gt;Hopefully we’ll be able to arrange that for London 2027, and maybe even add a little performance to the panel 🤞&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2026/maui-day-panel.jpg&quot; alt=&quot;The MAUI Day closing pannel&quot; title=&quot;The Ladybird packed into a suitcase&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;
</description>
        <pubDate>Fri, 06 Feb 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/maui-day-and-the-stowaway-ladybird/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/maui-day-and-the-stowaway-ladybird/</guid>
      </item>
    
      <item>
        <title>3 minutes to go. Stuck in the toilet 🤦‍♂️</title>
        <description>&lt;p&gt;3 mins left and I was stuck in the toilet&lt;/p&gt;

&lt;p&gt;It was broadcast time.&lt;/p&gt;

&lt;p&gt;Eat a Banana. Drink some water. Go to the bathroom.&lt;/p&gt;

&lt;p&gt;Gran always said to never miss an opportunity, and this wasn’t the moment to defy such hefty wisdom.&lt;/p&gt;

&lt;p&gt;Hearing the click of the door behind me, it didn’t cross my mind that this is the high security HQ of Microsoft. Doors have locks. A few minutes later I soon realised the consequences of this lapse in concentration.&lt;/p&gt;

&lt;p&gt;The call was made.&lt;/p&gt;

&lt;p&gt;I was rescued.&lt;/p&gt;

&lt;p&gt;Everything worked out in the end. But it wasn’t the warm-up I’d imagined for my guest appearance on the &lt;a href=&quot;https://www.youtube.com/watch?v=8X03ksPKX6A&quot;&gt;dotnet YouTube channel&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I wouldn’t worry about watching.&lt;/p&gt;

&lt;p&gt;My appearance amounts to just babbling about chocolate salty balls.&lt;/p&gt;

&lt;p&gt;They are tasty tho.&lt;/p&gt;
</description>
        <pubDate>Thu, 05 Feb 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/3-minuts-to-go/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/3-minuts-to-go/</guid>
      </item>
    
      <item>
        <title>Life - All about the bounce back</title>
        <description>
</description>
        <pubDate>Wed, 28 Jan 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/life-all-about-the-bounce-back/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/life-all-about-the-bounce-back/</guid>
      </item>
    
      <item>
        <title>.NET MAUI - Toggle fullscreen for MacCatalyst desktop app</title>
        <description>&lt;p&gt;.NET MAUI Mac Catalyst apps run on macOS, but use UIKit (iOS frameworks) rather than AppKit (native macOS frameworks). To toggle full screen, the same behavior as clicking the green ”zoom” button in the window chrome, we need to bridge into AppKit’s NSApplication and NSWindow using the Objective-C runtime.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;#if MACCATALYST

    var nsAppClass = ObjCRuntime.Class.GetHandle(&quot;NSApplication&quot;);
    var nsApp = ObjCRuntime.Runtime.GetNSObject(nsAppClass);
    var sharedApp = nsApp?.PerformSelector(new ObjCRuntime.Selector(&quot;sharedApplication&quot;));
    var keyWindow = sharedApp?.PerformSelector(new ObjCRuntime.Selector(&quot;keyWindow&quot;));
    keyWindow?.PerformSelector(new ObjCRuntime.Selector(&quot;toggleFullScreen:&quot;), keyWindow);

#endif
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;If you want to see it in action, check out &lt;a href=&quot;https://github.com/tonyedwardspz/MAUI-full-screen-mac-demo-project&quot;&gt;this demo project&lt;/a&gt; containing a Mac App.&lt;/p&gt;

&lt;p&gt;Here’s how it works.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;var nsAppClass = ObjCRuntime.Class.GetHandle(&quot;NSApplication&quot;);&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Gets a handle (pointer) to the Objective-C class &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSApplication&lt;/code&gt;. This is AppKit’s application singleton class that manages the macOS app lifecycle. Since Mac Catalyst doesn’t directly expose AppKit types to C#, we access it dynamically through the Objective-C runtime.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;var nsApp = ObjCRuntime.Runtime.GetNSObject(nsAppClass);&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Wraps the raw class handle in an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSObject&lt;/code&gt; that we can interact with from C#. This gives us a managed object representing the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSApplication&lt;/code&gt; class itself, which we get with the next line.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;var sharedApp = nsApp?.PerformSelector(new ObjCRuntime.Selector(&quot;sharedApplication&quot;));&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This calls the sharedApplication class method on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSApplication&lt;/code&gt;, which returns the singleton instance of the running application.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;var keyWindow = sharedApp?.PerformSelector(new ObjCRuntime.Selector(&quot;keyWindow&quot;));&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This retrieves the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;keyWindow property&lt;/code&gt; from the shared application, i.e. the window that will receive any keypresses. This returns an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSWindow&lt;/code&gt; instance. Then. . . finally. . .&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;keyWindow?.PerformSelector(new ObjCRuntime.Selector(&quot;toggleFullScreen:&quot;), keyWindow);&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Calls toggleFullScreen: on the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSWindow&lt;/code&gt;. This is the same method macOS invokes when you click the green full screen button. The method takes a sender parameter (we pass the window itself), and toggles between full screen and windowed mode. This mean you can call it again to revert the full screen.&lt;/p&gt;

&lt;p&gt;And with that, you now have a fullscreen Mac app using .NET MAUI.&lt;/p&gt;

&lt;h3 id=&quot;what-about-windows&quot;&gt;What about Windows?&lt;/h3&gt;

&lt;p&gt;If you want to implement the same functionality within MAUI for Windows, I highly recommend this &lt;a href=&quot;https://blog.verslu.is/maui/full-screen-disable-minimize-maximize-for-net-maui-windows-apps/&quot;&gt;blog post&lt;/a&gt;.&lt;/p&gt;
</description>
        <pubDate>Fri, 23 Jan 2026 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/muai-toggle-fullscreen-on-maccatalyst/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/muai-toggle-fullscreen-on-maccatalyst/</guid>
      </item>
    
      <item>
        <title>A MAUI example starter project</title>
        <description>&lt;p&gt;Setting a project up in the right way from day one is key to long term success. But with a myriad of different ways to achieve the same goal, it’s fairly easy to go down the wrong path. Especially as a learner. This is even more true with MAUI, given how much it’s changed in recent years.&lt;/p&gt;

&lt;p&gt;Quite often example projects are created by experts in software engineering, who’ve internalised abstractions and know the APIs inside out. These abstractions and helper libraries might make complete sense to someone with years of experience in a field, but to a learner, they’re can be plain confusing.&lt;/p&gt;

&lt;p&gt;To AI as well.&lt;/p&gt;

&lt;p&gt;By setting up the basics of a project, and then handing off to AI to fill in the gaps, the end result is much better and quicker to get to. With a little prompting, an AI coding assistant will scan a project and pattern match what exists already, making the right setup even more foundational and key.&lt;/p&gt;

&lt;p&gt;At the outset of a recent vibe Mac app, I created &lt;a href=&quot;https://github.com/tonyedwardspz/maui-di-vm-settings-example&quot;&gt;a boilerplate project&lt;/a&gt; to act as an example of a good starting point for a .NET MAUI app to grow from. It:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Uses view models via the community toolkit to allow for a clean separation of UI and business logic&lt;/li&gt;
  &lt;li&gt;Sets up dependency injection for services, and consumes them in the view models&lt;/li&gt;
  &lt;li&gt;Uses app settings to pull config from&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It achieves these things in the simplest way possible, without abstracting the reader of the code from how it fits together. It also offers a great starting point to experiment with using something more fancy to achieve the same goals, and will work on any platform if you alter the platform identifiers.&lt;/p&gt;

&lt;p&gt;Hopefully you’ll find it useful on your MAUI journey.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/tonyedwardspz/maui-di-vm-settings-example&quot;&gt;https://github.com/tonyedwardspz/maui-di-vm-settings-example&lt;/a&gt;&lt;/p&gt;
</description>
        <pubDate>Wed, 17 Dec 2025 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/maui-starter-project/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/maui-starter-project/</guid>
      </item>
    
      <item>
        <title>Festive friday at Sapphire House</title>
        <description>&lt;p&gt;34 people, 23 dishes, (only) 4 bottles of Prosecco, and 1 happy chef *.&lt;/p&gt;

&lt;p&gt;That’s a festive Friday at Sapphire House.&lt;/p&gt;

&lt;p&gt;To bring the team together before we start disappearing ahead of Christmas, the Sapphire Holidays IT team, joined up with parts of the Aspects Holidays and Classic Cottages teams to craft the ultimate office roast. Of course, this came shortly after giving out the presents.&lt;/p&gt;

&lt;p&gt;The meal itself was both a success and tasty!&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2025/ff-people.jpg&quot; alt=&quot;People enjoying the Festive food&quot; title=&quot;The festive friday food spread&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Honey glazed parsnips met crispy roast potatoes. Succulent beef collided with terrific turkey. Cute little piggies nestled in their blankets, with a trio of gravies smothering them all. It’s was one of those moments that having multiple full kitchens in the building became a major highlight of office life.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2025/ff-beef.jpg&quot; alt=&quot;Festive friday beef&quot; title=&quot;The festive friday food spread&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2025/ff-parsnips.jpg&quot; alt=&quot;Festive friday parsnips&quot; title=&quot;The festive friday food spread&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2025/ff-cabbage.jpg&quot; alt=&quot;Festive friday cabbage&quot; title=&quot;The festive friday food spread&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Unfortunately, expectations for 2026 are now super high.&lt;/p&gt;

&lt;p&gt;I suspect we’ll only top 2025 by getting a visit from Santa himself. . . although Dan was a adequate stand-in come time for secret Santa.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2025/ff-santa-dan.jpg&quot; alt=&quot;Santa Dan&quot; title=&quot;Santa Dan giving out presents&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Ok. There were actually two chefs. . . but only one of them regularly makes me bacon sandwiches so he’s getting all the credit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2025/ff-happy-chef.jpg&quot; alt=&quot;Happy chef&quot; title=&quot;Happy Chef after the meal&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;
</description>
        <pubDate>Mon, 15 Dec 2025 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/festive-friday/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/festive-friday/</guid>
      </item>
    
      <item>
        <title>10 years of Digital Plymouth</title>
        <description>&lt;p&gt;10 year of bringing Plymouth’s digital community together!&lt;/p&gt;

&lt;p&gt;Getting to come along for the journey has been an honor.&lt;/p&gt;

&lt;p&gt;We’re too quick to say that something is life changing nowadays. So much so that its become a cliché. But for this meetup, the people around it, and those that attend, it is often true in the truest sense of the word.&lt;/p&gt;

&lt;p&gt;Whenever I’m doing something amazingly fun professionally, in the farthest corners of Europe, I can ALWAYS trace the origin back to the encouragement and support that came from being a part of the Digital Plymouth / Plymouth Web / PlymouthJS communities 🙏&lt;/p&gt;

&lt;p&gt;It been great to watch it grow over the past decade into something bigger, more diverse, and even more impactful to the city.&lt;/p&gt;

&lt;p&gt;Here’s to 10 more years of the same 🥂&lt;/p&gt;

&lt;p&gt;A response to &lt;a href=&quot;This LinkedIn post&quot;&gt;https://www.linkedin.com/feed/update/urn:li:activity:7402436542780305408/&lt;/a&gt;.&lt;/p&gt;
</description>
        <pubDate>Thu, 04 Dec 2025 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/10-years-of-digital-plymouth/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/10-years-of-digital-plymouth/</guid>
      </item>
    
      <item>
        <title>Hanging up the wigs</title>
        <description>&lt;p&gt;I’ve hung up the wigs for 2025.&lt;/p&gt;

&lt;p&gt;But be warned. . . they’ll be back on occasion in 2026.&lt;/p&gt;

&lt;p&gt;In recent years, the two talks I’ve delivered the most have both had a gimmick. Something to create a photo opportunity as part of an event, and hopefully to put a smile on the audiences face.&lt;/p&gt;

&lt;p&gt;It worked!&lt;/p&gt;

&lt;p&gt;Well. . . most of the time.&lt;/p&gt;

&lt;p&gt;Without giving away the moment, the wigs and hats were both an integral part of the talks. But you (or even the organiser) can only play the fool so often, and it’s time to be a little more serious with the content I deliver. So whilst they’ll both be back, they’ll be used mainly for meetups where things are a little more informal and expectations on takeaway learnings are a little lower.&lt;/p&gt;

&lt;p&gt;But one of the projects behind the talk has changed a huge amount over the years. Plus it’s super fun to work on.&lt;/p&gt;

&lt;p&gt;Back then it was little more that a clone of a platform demo with a changed h1 tag, a ton of showmanship, and a dash of hot bars. Today, it’s grown into a multi API backend encompassing locally hosted speech recognition, LLMs, and image recognition paired with either a website or .NET tMAUI desktop app as the interface. It’s even binging in the hot newness that is Aspire to both the web and .NET stacks.&lt;/p&gt;

&lt;p&gt;Naturally, the software is a bit shaky. That’s part of the fun. However the learnings are solid and it keeps a mechanism for growing my hat collection a little more.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2025/hats.jpg&quot; alt=&quot;Hat collection&quot; title=&quot;Hat collection&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The trickiest part?&lt;/p&gt;

&lt;p&gt;Fitting the details into a paragraph or two so I can submit to conferences!&lt;/p&gt;

&lt;p&gt;Wish me luck 🤞&lt;/p&gt;
</description>
        <pubDate>Fri, 28 Nov 2025 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/hanging-up-the-wigs/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/hanging-up-the-wigs/</guid>
      </item>
    
      <item>
        <title>The value of user testing</title>
        <description>&lt;p&gt;At work, the project I head up is entering its final phase of development before release. To make sure that we’re making guests life easier, I’ve hosted a user testing session in recent days. The results are surprisingly unsurprising. Bugs have been flagged up, paths through the app were broken, and features were not used as expected.&lt;/p&gt;

&lt;p&gt;It was enlightening!&lt;/p&gt;

&lt;p&gt;Whilst we’re well past the prototyping phase, the &lt;a href=&quot;https://www.sitepoint.com/premium/books/designing-ux-prototyping/&quot;&gt;Designing UX: Prototyping&lt;/a&gt; book by &lt;a href=&quot;https://bsky.app/profile/bouncingdan.bsky.social&quot;&gt;Dan Goodwin&lt;/a&gt; and Ben has been super useful in setting up this activity. It pushed me into the right mindset for testing in a high fidelity context, giving a great framework for asking the right questions and setting the right tasks for users to work though. Although some of the tooling in the book in out dated, the concepts are solid and will never not be relevant. It’s also a super easy read, and pairs up well with lessons withing ‘The Mum Test’. Both are highly recommended if you’re about to perform this type of task.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2025/testing-book.jpg&quot; alt=&quot;Designing UX: Prototyping book&quot; title=&quot;Designing UX: Prototyping&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;

&lt;p&gt;On the day, test users were provided with four groups of related tasks, the kind of things that guests will over the lifecycle of their stay. Feedback was gathered via both a form and conversation, plus observational notes to pair with screen and audio recordings. The perspective afforded by looking over a shoulder instead of testing yourself allows different elements of the product to jump out. E.g,&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I never miss the hit box to open the account edit screen, but every single tester did.&lt;/li&gt;
  &lt;li&gt;Users consistently look for X as part of the search interface.&lt;/li&gt;
  &lt;li&gt;I think feature X is complete, but the people from within the brand think Y and Z are missing from it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;https://tonyedwardspz.co.uk/assets/images/2025/testing-user2.jpg&quot; alt=&quot;Testing MAUI apps with users&quot; title=&quot;Testing MAUI apps with users&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Talking with frontline staff, those on the end of the phone during both the booking process and during the stay, uncovered a bunch of easy wins for the product.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;“20% of our calls are about X”&lt;/li&gt;
  &lt;li&gt;“Our life would be easier if guests had Y when they called up”&lt;/li&gt;
  &lt;li&gt;“99% of guests only have one booking at a time”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The process also threw me back to a conversation at the &lt;a href=&quot;https://tonyedwardspz.co.uk/blog/product-tank-cologne/&quot;&gt;Product Task meetup in Köln&lt;/a&gt;. I was talking about testing with a fellow attendee, with the conversation continuing afterwards digitally. I highlighted that my test group will come from within the company. It was suggested that they’d use the product differently to actual guests due to institutional knowledge, whilst I exibited an undeserved level of hubris that they wouldn’t.&lt;/p&gt;

&lt;p&gt;Surprise surprise. . . they did.&lt;/p&gt;

&lt;p&gt;One of the tasks was to navigate to a booked property, effectivly the stay starting tomorrow. Everyone within the business checked to see if the textual directions were present in the app. Everyone outside of the business simply clicked the map to start navigation within their maps app. This really highlighted how institutional knowledge affects the was a user performs a task. Martin. You were right. . . but only one of us knew that at the time! To paraphrase Tim Herberg, the speaker on the night:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Gut feeling has a place in the discovery process. It can inform the direction in the early days, but should quickly be backed up or discarded due to data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Unfortunatly, I’d forgotten that point in the sime since and neglected to get the data to back up my assumptions.&lt;/p&gt;

&lt;p&gt;Overall this was an enlightening process, and one that reminded me of the value that the rapid feedback loop that often comes with an agile-esque development process. Whilst some of the things that were flagged up are solid suggestions, there’s not really enough time to both polish what exists and introduce the refined features before launch day.&lt;/p&gt;

&lt;p&gt;Lesson learned.&lt;/p&gt;

&lt;p&gt;This was a fun and very useful process to go through as a product developer. I’m now sitting on a pile of actionable feedback, insights from different levels of the business, and even a few ideas that can help make the wider teams life easier via minimal changes to existing products or a quickly vibed prototype.&lt;/p&gt;

&lt;p&gt;Oh. . . and biscuits. We ate a lot of biscuits.&lt;/p&gt;
</description>
        <pubDate>Wed, 26 Nov 2025 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/the-value-of-testing/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/the-value-of-testing/</guid>
      </item>
    
      <item>
        <title>Looking around @ HalfStack London</title>
        <description>
</description>
        <pubDate>Fri, 21 Nov 2025 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/looking-around-at-halfstack/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/looking-around-at-halfstack/</guid>
      </item>
    
      <item>
        <title>Rings - Aesop Rock</title>
        <description>&lt;p&gt;Used to draw&lt;br /&gt;
Hard to admit that I used to draw&lt;br /&gt;
Portraiture and the human form&lt;br /&gt;
Doodle of a two-headed unicorn&lt;br /&gt;
It was soothin’, movin’ his arm in a fusion&lt;br /&gt;
Of man-made tools and a muse from beyond&lt;br /&gt;
Even if it went beautifully wrong&lt;br /&gt;
It was tangible truth for a youth who refused to belong&lt;br /&gt;
No name nuisance, stews in a bedroom&lt;br /&gt;
Oozing a brand new cuneiform&lt;br /&gt;
Barely commune with the horde&lt;br /&gt;
Got a whole gray scale ungluing his world&lt;/p&gt;

&lt;p&gt;Might zone out to the yap of the magpie&lt;br /&gt;
Unseen hand dragging his graphite&lt;br /&gt;
Cross contour, little bit of backlight&lt;br /&gt;
Black ink after a Bristol to baptize&lt;br /&gt;
You can’t imagine the rush that ensue&lt;br /&gt;
When you get three dimensions stuffed into two&lt;br /&gt;
Then it’s off to a school where it’s all that you do&lt;br /&gt;
Being trained and observed by a capable few&lt;br /&gt;
Back in New York, five peeps and a dog&lt;br /&gt;
In a two-bedroom doing menial jobs&lt;br /&gt;
Plus, rhymin’ and stealin’ and being a clod&lt;br /&gt;
Distractions free to maraud&lt;/p&gt;

&lt;p&gt;I left some years a deer in the light&lt;br /&gt;
I left some will to spirit away&lt;br /&gt;
I let my fears materialize&lt;br /&gt;
I let my skills deteriorate&lt;br /&gt;
Haunted by the thought of what I should have been continuing&lt;br /&gt;
A mission that was rooted in a 20-year affinity&lt;br /&gt;
In rickety condition with an ID crisis&lt;br /&gt;
Nap on the front lawn, look up in the sky, it’s…&lt;/p&gt;

&lt;p&gt;Shapes falling out of the fringe&lt;br /&gt;
All heart, though we would’ve made cowardly kings&lt;br /&gt;
They will chop you down just to count your rings&lt;br /&gt;
Just to count your rings, just to count your rings&lt;br /&gt;
And there were colors pouring out of the fringe&lt;br /&gt;
All heart, though we would’ve made cowardly kings&lt;br /&gt;
They will chop you down just to count your rings&lt;br /&gt;
Just to count your rings, just to count your rings&lt;/p&gt;

&lt;p&gt;Used to paint&lt;br /&gt;
Hard to admit that I used to paint&lt;br /&gt;
Natural light on a human face&lt;br /&gt;
Stenciled fire on his roommate’s bass&lt;br /&gt;
It was blooming addiction&lt;br /&gt;
Amiss in the pushing of pigment&lt;br /&gt;
Book like a tattooed pigskin, look&lt;br /&gt;
Pinhead kids of the minute&lt;br /&gt;
Drank Kool-Aid from a tube of acrylic&lt;br /&gt;
And it grew up into linseed oil over linen&lt;br /&gt;
Joy to the poison, voice of the resin&lt;br /&gt;
Capture a map of the gesture&lt;br /&gt;
Back up, add a little accurate fat to the figure&lt;/p&gt;

&lt;p&gt;Redo that, move that inward&lt;br /&gt;
Zinc white lightning shoots from his fingers&lt;br /&gt;
Studio strewn with illusion and tinctures&lt;br /&gt;
Stay tuned for the spooky adventures&lt;br /&gt;
You can’t imagine the stars that align&lt;br /&gt;
When a forearm starts foreshortening right&lt;br /&gt;
Or a torso hung on a warping spine&lt;br /&gt;
In proportion reads as warm and alive&lt;br /&gt;
Routine day with a dirt cheap brush&lt;br /&gt;
Then a week goes by and it goes untouched&lt;br /&gt;
Then two, then three, then a month&lt;br /&gt;
And the rest of your life, you beat yourself up&lt;/p&gt;

&lt;p&gt;I left some seasons eager to fall&lt;br /&gt;
I left some work to bury alive&lt;br /&gt;
I let my means of being dissolve&lt;br /&gt;
I let my person curl up and die&lt;br /&gt;
Eating up his innards, an unfeasible anxiety&lt;br /&gt;
Has brutally committed to relinquishing his privacy&lt;br /&gt;
Aligning with the trials of the anti-Midas&lt;br /&gt;
Nap on the back lawn, look up in the sky, it’s…&lt;/p&gt;

&lt;p&gt;Shapes falling out of the fringe&lt;br /&gt;
All heart, though we would’ve made cowardly kings&lt;br /&gt;
They will chop you down just to count your rings&lt;br /&gt;
Just to count your rings, just to count your rings&lt;br /&gt;
And there were colors pouring out of the fringe&lt;br /&gt;
All heart, though we would’ve made cowardly kings&lt;br /&gt;
They will chop you down just to count your rings&lt;br /&gt;
Just to count your rings, just to count your rings&lt;/p&gt;
</description>
        <pubDate>Tue, 18 Nov 2025 00:00:00 +0000</pubDate>
        <link>https://tonyedwardspz.co.uk/blog/rings/</link>
        <guid isPermaLink="true">https://tonyedwardspz.co.uk/blog/rings/</guid>
      </item>
    
  </channel>
</rss>
