Previously, in Part 4, the phone became the third client of the clinic's queue hub, and the big number moves the moment reception taps "Call next". But a number that changes on a screen nobody is looking at has changed for nobody. This part gives the app a way to interrupt — a tap, a buzz, a line in the notification tray — and spends most of its effort deciding when it has earned the right to.

The prompt: two verbs and a moment

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

"Two more capabilities. IHaptics with exactly two verbs — Tap (that
registered) and Buzz (look at me now) — because an app that vibrates for
everything gets muted. INotifier with permission as a separate step, asked
at check-in where the reason is obvious, never at launch. MAUI: haptics via
MAUI Essentials in one file; notifications as a partial class with the body
per platform — Android with a channel, the Android 13 runtime permission and
NotificationCompat, no plugin; Windows through the App SDK toast API;
iOS/Mac honest no-ops since they aren't built here. Web: navigator.vibrate
and the Notification API over JS interop, registered per circuit. On the
Visit screen only TRANSITIONS earn a nudge: position becomes 1 → tap +
'You're next'; status becomes your turn → buzz + 'It's your turn'. Prove it
on the emulator, and prove the buzzes with the OS's own vibrator log."

Two decisions are made before any code exists. "Exactly two verbs" is a restraint rule — a vocabulary so small it can't be abused. "Permission as a separate step, asked at check-in" is a timing rule — an app that asks on its splash screen is a stranger asking a favor. And the last clause asks for evidence from the operating system's own log, because a screenshot cannot show a vibration.

Model pick: Opus, high effort, for the two-verbs and permission-in-context design — product decisions with a long shelf life. The platform bodies went to Sonnet: a channel, a builder chain, a toast — precisely specified, exactly its shape of work. The most expensive tokens of the part went on something neither model could fix; that story is below.

Two verbs, because muted apps don't buzz

The capability-interface pattern from Part 2 continues: the shared project asks a question, each host answers it. Capability three is the shortest interface of the season, and its comment is the whole argument:

/// Capability #3: physical feedback. Two verbs only — a phone app that vibrates for
/// everything is a phone app people mute.
public interface IHaptics
{
    bool IsSupported { get; }

    /// A light click: "that tap registered".
    void Tap();

    /// An attention buzz: "look at me now" — it's your turn.
    void Buzz();
}

No Vibrate(pattern), no intensity, no duration. A screen that wants a third feeling has to argue for a third verb in a code review, which is the point. On MAUI, for once, one file covers every platform, because HapticFeedback and Vibration are MAUI Essentials:

public sealed class Haptics : IHaptics
{
    public bool IsSupported => HapticFeedback.Default.IsSupported || Vibration.Default.IsSupported;

    public void Tap()
    {
        try
        {
            HapticFeedback.Default.Perform(HapticFeedbackType.Click);
        }
        catch (FeatureNotSupportedException)
        {
            // desktops and some tablets: silently nothing
        }
    }

    public void Buzz()
    {
        /* … Vibration.Default.Vibrate(TimeSpan.FromMilliseconds(400)), same guard … */
    }
}

Android wants VIBRATE in the manifest for the buzz; the click needs nothing. The browser host answers with navigator.vibrate — 30 ms for a tap, a 200-100-200 pattern for a buzz — which exists on Android Chrome and, as its own comment says, "nowhere else that matters". Both answers are honest; the shared UI copes with either.

Permission is a separate step — and the step is check-in

/// Asking permission is a separate step on purpose: ask at a moment that explains
/// itself ("we'll tell you when it's your turn"), never at launch.
public interface INotifier
{
    Task<bool> RequestPermissionAsync();

    Task ShowAsync(string title, string body);
}

The Visit screen chooses the moment. The sentence above the check-in button already says "we'll buzz you when you're next, and again when it's your turn", and the first line of CheckInAsync is await Notifier.RequestPermissionAsync() — the question arrives right after the promise, when a person is most likely to say yes. A successful check-in earns a Tap(). Nothing at launch earns anything.

The My visit screen on the Android emulator, dimmed behind a system dialog: a bell icon, 'Allow ClinicLive Pocket to send you notifications?', and two pale blue buttons, Allow and Don't allow. Behind it, Liam's booked visit for today at 16:30 and the start of the sentence 'You're here? Check in and take a seat — we'll buzz you when you're next…'.
The Android 13 runtime permission, asked in context: Liam has just tapped "I'm here — check me in", and the reason for the question is still readable behind it.

One shape, a body per platform

MAUI has no cross-platform notification API, and the prompt refuses a plugin. The MAUI-idiomatic answer is a partial class: the shape in Services/, a body in each folder under Platforms/, and only the file for the platform being built is compiled.

public sealed partial class Notifier : INotifier
{
    public partial Task<bool> RequestPermissionAsync();

    public partial Task ShowAsync(string title, string body);
}

The Android body is the one worth seeing — a channel (mandatory since 8.0), the POST_NOTIFICATIONS runtime permission (mandatory since 13; MAUI's Permissions.PostNotifications does the version check), and NotificationCompat to build the thing. About forty lines, no package:

public sealed partial class Notifier
{
    public const string ChannelId = "queue";

    public partial async Task<bool> RequestPermissionAsync()
    {
        /* … CheckStatusAsync, then RequestAsync<Permissions.PostNotifications> … */
    }

    public partial Task ShowAsync(string title, string body)
    {
        EnsureChannel();
        /* … NotificationManager, a launch intent so a tap opens the app, the icon by name … */
        var notification = new NotificationCompat.Builder(context, ChannelId)
            .SetSmallIcon(icon)
            .SetContentTitle(title)
            .SetContentText(body)
            .SetPriority(NotificationCompat.PriorityHigh)
            .SetAutoCancel(true)
            .SetContentIntent(tap)
            .Build();

        manager.Notify(Interlocked.Increment(ref _nextId), notification);
        return Task.CompletedTask;
    }
}

Windows is a dozen lines through the Windows App SDK — build an AppNotification, Show() it — with no permission dialog at all: the user decides in Settings › Notifications, and Setting == Enabled reports what they chose. iOS and Mac Catalyst get honest no-ops; Part 1 said there's no Mac on this bench, and a no-op that says so beats a body nobody ran. The browser host calls the Notification API through JS interop, and its registration carries the one detail that matters on Blazor Server: scoped, not singleton, because IJSRuntime belongs to one visitor's circuit.

Only transitions earn a nudge

Here is the sentence the whole part hangs on. The Visit screen reloads itself constantly — every hub signal, every resume, every check-in — and most reloads change nothing. If every reload could buzz, the phone would buzz whenever someone else checked in. So the page compares the visit before a load with the visit after it, and only two transitions count:

/// Only TRANSITIONS earn a buzz — never the first load, never a refresh that
/// changed nothing. Two moments matter to someone in a waiting room: "you're
/// next" (get your things together) and "it's your turn" (go now).
private async Task NudgeIfItMattersAsync(VisitDto before, VisitDto after)
{
    if (after.Status == VisitStatus.InProgress && before.Status != VisitStatus.InProgress)
    {
        Haptics.Buzz();
        await Notifier.ShowAsync("It's your turn", $"{after.FirstName}, please go through now.");
    }
    else if (after.Status == VisitStatus.CheckedIn && after.Position == 1 && before.Position != 1)
    {
        Haptics.Tap();
        await Notifier.ShowAsync("You're next", "Get ready — you're first in the queue.");
    }
}

Tap for "next", buzz for "turn" — the two verbs mapped to the two moments, and nothing else in the app may call either. The demo: Liam (booking VVPYPN, 16:30) allows notifications and lands at position 2 of three waiting — ahead of Sofia, who checked in first but holds the 16:45 slot. That's season one's queue rule, slot time first, now visible from a phone. Then reception taps "Call next" twice.

The My visit screen after allowing notifications: Hi Liam with a 'Checked in' pill, Today at 16:30, the label 'Your place in the queue' above a very large petrol 2, then '3 people waiting. Now serving Emma W.' and, under the card, 'Live — updates the moment the queue moves.' Allowed, checked in
The Android notification shade at 12:35: quick-settings tiles for Internet, Bluetooth, Flashlight and Do Not Disturb, then a ClinicLive Pocket group of two notifications — 'It's your turn: Liam, please go through now.' and 'You're next: Get ready — you're first in the qu…' — and below, in a Silent section, the emulator's own 'Serial console enabled' notice. Two call-nexts later
Left: position 2, a tap for the check-in and nothing else. Right: both nudges in the shade after two "Call next" taps at reception — "You're next" first, then "It's your turn" stacked on top of it. The "Serial console enabled" line belongs to the emulator, not the app.

Proof a screenshot can't give

The shade proves the notifications and nothing about the vibrations. Android keeps its own record: adb shell dumpsys vibrator_manager lists every vibration the system played, with its source and length. After the demo it listed three from this app: a 182 ms click at check-in, a 102 ms click for "you're next", and a 403 ms buzz for "it's your turn" — our 400 ms request as the device ran it. Two clicks of different lengths is fine; the OS owns how a click feels, we only asked for one. The part of the demo nobody can photograph, signed off by the vibrator service.

The toast that never came

Now the honest half. On Windows, "Send a test notification" walks the same code: Register() succeeds, Setting is Enabled — the card says "Allowed" — and Show() returns without complaint. No toast appears. Not in the corner, not in the notification center, not anywhere on a full-screen capture.

The Windows app in dark mode, in a normal window: the Settings page with a Nudges card reading Haptics 'Available on this device', Notifications 'Not asked yet', and three outlined buttons — Try a tap, Try a buzz, Send a test notification. An About card says WinUI 10.0.26200.9168, Native app, Large screen. A bottom tab bar stretches across the full width. Before
The same Settings page maximized on a 4K display after tapping Send a test notification: Notifications now reads 'Allowed', everything else is identical, and there is no toast anywhere on the screen — the bottom-right corner where Windows shows notifications is empty. After "Send a test notification"
The frame this part has to show. "Allowed" is the only thing that changed; the corner a banner would appear in is empty. The after shot is maximized because the screenshot harness now refuses full-screen captures otherwise (see below). Also visible and known: "Haptics: Available on this device" is MAUI being optimistic about a desktop, and the tab bar stretched across a 4K monitor is Part 10's whole subject.

The chase went as far as it honestly could. The app's identity is registered with a name and an icon. The docs say an unpackaged app must register before its first window exists, so Register() moved into OnLaunched: no change. Then the decisive test: a toast sent to the app's identity from PowerShell, through the same WinRT API, is silent too — while one sent under PowerShell's own identity banners immediately. The OS is suppressing this identity, not our code. That's where this part stops. Part 10 names the likely cause — an unpackaged debug build has no package identity — and Part 11 explains why the series doesn't fake the fix. Android is fully proven; Windows toasts stay open for the season.

What the AI got wrong: mostly, this part is about what it couldn't get right. Every Windows call reported success while nothing happened, and the AI re-read the docs, moved the registration and rebuilt before questioning whether the problem was inside the process at all. The lesson is a stopping rule: when the OS says yes and shows nothing, prove it's the OS with a tool that isn't your app — the PowerShell test settled it in minutes. Smaller: "Haptics: Available on this device" on a desktop is HapticFeedback.IsSupported being optimistic, and the browser host says the same because a server can't feel a visitor's phone — cosmetic, noted rather than hidden. And one catch in the tooling: the first full-screen capture on Windows photographed the whole desktop, other windows included. It was deleted and never committed, and Shot-Windows.ps1 now refuses -FullScreen unless the app window is maximized, and excludes the taskbar. A screenshot harness needs a privacy guard, not just a crop.

The meter: ≈ $3.40 on season three's running meter. The Windows toast chase cost most of this part — a dead end is billed at the same rate as a feature, which is the best argument for deciding early what evidence would end it.

Checkpoint: git checkout pocket-05 in the repo: run the clinic and the Android app, open one of today's demo bookings and check in — the permission dialog appears over the visit, not at launch; two "Call next" taps at reception put both nudges in the shade; adb shell dumpsys vibrator_manager shows the three vibrations. The suite is still 13-for-13 — nothing in this part is reachable by a unit test, which is exactly why the evidence came from the OS.

Everything here shares one weakness: it runs inside the app. The hub from Part 4 and the nudges from this part both need the process alive, and patients close apps. Reaching a phone whose app isn't running means a server, a token and Firebase — and a lesson about what "closed" actually means on Android — Part 6: Push, for Real: Firebase Cloud Messaging to a Closed App.