Previously, in Part 1, we drew the map: one set of Razor screens, a MAUI host for the phone and the desk, a web host for the browser, and a rule that every native feature is an interface each host answers honestly. Now the map becomes projects. This part scaffolds the season, strips the template, moves the porcelain-and-petrol tokens into a phone-first stylesheet, proves the pattern with the smallest possible capability — and photographs the result on three hosts, which is where the trouble starts.

The prompt: architecture first, pixels second

Verbatim from the commit in the repo (tag pocket-02):

"Season three: ClinicLive gets a patient companion app — .NET MAUI
Blazor Hybrid for Android and Windows, PLUS a Blazor web host, sharing every
screen. Scaffold from the maui-blazor-web template into src/ (App, .Shared,
.Web) and add a tiny ClinicLive.Contracts project for the DTOs the phone and
server will share — share the SHAPE of the data, never the server's code.
Strip the template (Bootstrap, Counter/Weather, sidebar). Carry Season 2's
porcelain & petrol tokens into a phone-first pocket.css with a bottom tab
bar, but use the platform's own font. Establish the capability-interface
pattern with the simplest capability — IPlatformInfo — implemented once per
host, and a Settings page that shows which host is rendering. App id
com.cliniclive.pocket, petrol icon and splash. Photograph it on the
emulator, on Windows and in a browser."

Read the shape of it. Two sentences are architecture ("share the SHAPE of the data, never the server's code"; "implemented once per host"), one is a design constraint that overrides a season-two decision ("the platform's own font"), and the last is an instruction to produce evidence. Season two taught us that a design prompt without a photograph at the end is a prompt that declares itself finished; this season's prompts end with a camera every time.

Model pick: Opus, high effort. A contracts project and a capability-interface pattern are decisions every later part will live inside, and — as it turned out — the diagnosis of what Android does to a WebView's edges is a judgment call, not a lookup. Sonnet would have done fine for the stylesheet.

Four projects, one set of screens

dotnet new maui-blazor-web produces three projects; the fourth is ours, and its project file says why it exists:

<!-- The wire contract between ClinicLive's public API and the Pocket app.
     Deliberately tiny and dependency-free: the phone shares the SHAPE of the
     data with the server, never the server's code. -->

The shared library is where every screen lives, once — Home, Visit, Directions, Settings, a layout with a bottom tab bar, and pocket.css — and the two hosts are thin: each one renders the same Routes component and registers its own answers to the same questions. The MAUI host targets Android everywhere, adds Windows when built on Windows, and iOS and Mac Catalyst when built anywhere but Linux:

<TargetFrameworks>net10.0-android</TargetFrameworks>
<TargetFrameworks Condition="!$([MSBuild]::IsOSPlatform('linux'))">$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>

<ApplicationTitle>ClinicLive Pocket</ApplicationTitle>
<ApplicationId>com.cliniclive.pocket</ApplicationId>

The iOS and Mac Catalyst targets compile in that list; as Part 1 said, they were never built or run — there's no Mac on this bench, and pretending otherwise would be the opposite of what this series is for.

The capability-interface pattern, in three files

Part 1 showed the interface. Here are the two answers — MAUI's, from the device:

/// <summary>MAUI's answer: ask the device.</summary>
public sealed class PlatformInfo : IPlatformInfo
{
    public string Platform => DeviceInfo.Platform.ToString();

    public string Version => DeviceInfo.VersionString;

    public bool IsPhone => DeviceInfo.Idiom == DeviceIdiom.Phone;

    public string Host => "Native app";
}

and the browser's, which has to admit something a native app never does:

/// <summary>The browser's answer. It renders on the server, so "version" is the server's — say so.</summary>
public sealed class PlatformInfo : IPlatformInfo
{
    public string Platform => "Web";

    public string Version => $"(server: {Environment.OSVersion.Platform})";

    // Server-side rendering can't see the screen; the CSS decides the layout instead.
    public bool IsPhone => false;

    public string Host => "Browser";
}

Each host registers its own class against the shared interface — builder.Services.AddSingleton<IPlatformInfo, PlatformInfo>() in MauiProgram.cs and again in the web host's Program.cs — and the Settings page, which lives in the shared project and has never heard of either host, simply asks:

@page "/settings"
@inject IPlatformInfo Platform

<dl class="facts">
    <dt>Running on</dt>
    <dd>@Platform.Platform @Platform.Version</dd>
    <dt>Host</dt>
    <dd>@Platform.Host</dd>
    <dt>Layout</dt>
    <dd>@(Platform.IsPhone ? "Phone (one hand, thumbs)" : "Large screen")</dd>
</dl>
The Settings page in the Android emulator: a petrol status bar, the ClinicLive Pocket header, and a white 'About this app' card reading Running on Android 13, Host Native app, Layout Phone (one hand, thumbs), with the note 'One set of screens, three hosts…' underneath; a four-tab bar at the bottom shows Settings highlighted. Android emulator
The same Settings page in a 375-pixel-wide browser: identical card and wording, but reading Running on Web (server: Win32NT), Host Browser, Layout Large screen. Browser (web host)
The shot of the part: one Razor component, two honest answers. "Android 13 · Native app · Phone" on the emulator; "Web (server: Win32NT) · Browser · Large screen" in Chrome. Two of the browser's answers look odd until you re-read the class above: the version is the server's, because that's where a Blazor Server page runs, and "Large screen" at 375 pixels is the server admitting it can't see the screen — layout is CSS's job there. Both answers are true, which is the whole point.

Porcelain & petrol, in the platform's own type

The tokens are season two's, moved over almost line for line — the same porcelain background, petrol primary, coral warning, the same dark set under prefers-color-scheme — with one deliberate change that the stylesheet's own header explains:

/* ============================================================
   ClinicLive Pocket — the same "porcelain & petrol" tokens as the
   clinic's web app (From Prompt to Polish, Part 2), re-tuned for a
   phone held in one hand.

   Rules of the house, unchanged:
   - Every color, size and radius is a token.
   - Light and dark are the same system; components don't change.
   - One difference from the web app: the TYPE is the platform's own
     (Roboto on Android, Segoe on Windows). A native app that ships a
     web font feels like a website in a trench coat.
   ============================================================ */

--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;

Season two chose Atkinson Hyperlegible for good reasons, and it stays on the website. But an app that arrives on your phone in a typeface no other app uses announces, before you've read a word, that it's a web page in disguise. The rest of the phone-first re-tuning is small and specific: a fixed bottom tab bar (Home / My visit / Find us / Settings), a content column capped at 32rem, and the confirmation-code input treated as a stage — 2.2rem monospace with wide letter-spacing, because that code is the one thing a patient has to type.

The Pocket home screen on the Android emulator: petrol status bar, ClinicLive Pocket header, the heading 'Your visit, in your pocket.' with a short intro, and a white card titled Find my appointment holding a wide six-dot code input and a pale, disabled 'Show my visit' button; the tab bar at the bottom shows Home highlighted.
Home after the fixes below, so this frame is quiet: petrol status bar, header where a header goes, tab labels visible above the gesture bar. The button is deliberately faded — it stays disabled until six characters are typed. Roboto throughout, and nothing on screen you'd call a website.

What the first Android screenshot caught

The harness for a phone app is embarrassingly small — adb shell screencap, adb pull, and a launch via monkey because MAUI mangles the activity name — and it earned its keep on the very first frame. The status bar was purple. Not petrol: the Android template's purple, #512BD4, because the status bar is painted by Android from colors.xml and no CSS token can reach it. The fix lives in the same file, with its story attached:

<!-- The status bar and splash are painted by Android from THESE, not from CSS.
     The template's purple survived the first screenshot; the tokens live here too. -->
<color name="colorPrimary">#16696F</color>
<color name="colorPrimaryDark">#0F4A4E</color>
<color name="colorAccent">#C96F4A</color>

Catch two was bigger. The header was missing, and so were the tab labels. Windows showed both, so the CSS was fine — which is exactly what made it confusing. Android hands the WebView the whole screen, under the status bar and under the gesture bar, and the web's answer to that, env(safe-area-inset-*), reads zero inside a WebView. Our header was drawn behind the clock and our tab labels behind the home pill. The fix is not CSS at all; it's native, in MainActivity:

// The first screenshot: our header sat BEHIND the status bar and the tab labels
// BEHIND the gesture bar. Android hands the WebView the whole screen, and CSS's
// env(safe-area-inset-*) reads 0 inside a WebView — so the insets have to be
// applied here, natively, as padding on the content view.
var content = FindViewById<ViewGroup>(Android.Resource.Id.Content);
if (content is not null)
{
    ViewCompat.SetOnApplyWindowInsetsListener(content, new SystemBarsPadding());
}

/* … */

private sealed class SystemBarsPadding : Java.Lang.Object, IOnApplyWindowInsetsListener
{
    // Android.Views.View spelled out: inside a MAUI project, bare "View" is
    // Microsoft.Maui.Controls.View — the compiler's first complaint on this file.
    public WindowInsetsCompat OnApplyWindowInsets(Android.Views.View? v, WindowInsetsCompat? insets)
    {
        /* … null checks … */
        var bars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars());
        v.SetPadding(bars.Left, bars.Top, bars.Right, bars.Bottom);
        return WindowInsetsCompat.Consumed!;
    }
}

That comment about View is catch three, and it's the fix failing to compile: inside a MAUI project the bare word View means Microsoft.Maui.Controls.View, and the AI, writing Android code, reached for Android's. One fully-qualified name later it built. Catches four and five came from the other two hosts. On Windows the h1 focus ring from season two's Part 2 was back from the dead: that fix was h1:focus, but FocusOnNavigate gives the heading tabindex="-1", so it now matched the [tabindex] in our ring rule and won on specificity. Same bug, new face:

/* FocusOnNavigate gives the h1 tabindex="-1" so screen readers land on it — which
   also makes it match [tabindex] above. Same bug as the web app's Part 2, new face. */
h1[tabindex]:focus, h1[tabindex]:focus-visible { outline: none; }

And the web host, photographed by the season-two harness, looked like 1996: unstyled text, no icon, nothing. Every static file was being served as 200 with a Content-Length of zero, because the harness ran it with dotnet run --no-launch-profile — which means no environment variable, which means Production, where MapStaticAssets expects published assets that don't exist in a dev checkout. Set ASPNETCORE_ENVIRONMENT=Development explicitly and the stylesheet came back. Hold onto that one; it bites the server too in Part 3. (A sixth catch was pure tooling: dotnet run -nologo -- args forwards -nologo into the app's own arguments, and for one run Chromium cheerfully photographed a directory listing. Don't mix MSBuild switches into dotnet run.)

The Pocket app as a Windows desktop window in dark mode, maximized on a very wide monitor: the Find my appointment card sits alone in the middle of a huge dark area, and the four tab labels — Home, My visit, Find us, Settings — are spread across the entire bottom edge of the window, hundreds of pixels apart.
Windows, first run — and the seventh thing the screenshots noticed. The window opens maximized, and the phone layout is stretched across the whole monitor: four tab labels a long walk apart, one small card marooned in the middle. The dark palette is the OS theme doing its job, correctly. This isn't a bug yet; it's the whole subject of Part 10, and it's kept here because a series that only shows the after frames isn't showing you the work.

What the AI got wrong: three of the catches were the same mistake in different clothes — assuming the web owns the whole screen. A status bar painted from XML, a safe area the WebView can't see, a word (View) that MAUI had already taken: each one is a place where "it's just HTML" stops being true, and none of them show in the code. The lesson web developers don't have yet: the safe area is the OS's business, not CSS's. The other two — the focus ring's return and the Production-by-default web host — are season-two lessons resurfacing, which is its own lesson: a fix that worked once isn't a fix you can stop checking.

The meter: ≈ $1.30 on season three's running meter. The scaffold and the stylesheet were cheap; the screenshots weren't — every frame the AI looks at is image tokens, and this part looked at a lot of them, on three hosts, before and after five fixes. That ratio is going to be the story of the season.

Checkpoint: git checkout pocket-02 in the repo: four projects under src/; dotnet build src/ClinicLive.Pocket -f net10.0-android -t:Install puts the app on a running emulator, the Windows target builds on Windows, and dotnet run in ClinicLive.Pocket.Web serves the same screens to a browser. Open Settings on each and read the answers. Nothing talks to the clinic yet, and the season-one test suite is untouched at nine green.

The Settings page is proof of architecture, not a feature — and My visit currently says "Looking up…" followed by "The clinic's API arrives in Part 3", because a phone can't talk to Postgres. The clinic needs a door: four small endpoints over the season-one services, DTOs the phone shares by shape, the emulator's peculiar address for "my PC", and a real visit screen — Part 3: The App Needs a Door: A Thin API Over the Clinic.