You're reading the Rask v1.0.0 guides.View source
All guides

Browser APIs — reference & live demos

Every typed browser wrapper with a runnable demo showing its C# source beside the live result.

‹ Back to Browser APIs

API reference — live demos

Every wrapper below runs live and identically on both transports. Each demo shows its C# source beside the running result (some are device/permission-dependent and no-op in a headless or desktop browser — try them on a phone). The WASM-only device APIs (Serial, USB, HID, Bluetooth) and the installation/PWA APIs live in the Mobile & PWA guide.

Storage & persistence

IBrowserStorage — typed, awaitable localStorage / sessionStorage.

StorageDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IBrowserStorage" /> — a <c>localStorage</c> round-trip, injected through the ctor and
///     identical on Server and WASM.
/// </summary>
public sealed partial class StorageDemo(IBrowserStorage storage) : Component
{
    private const string StorageKey = "rask.browser.storage";

    private string _input = string.Empty;
    private string? _read;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("mb-2 flex gap-2")[
                    Input.Value(_input)
                        .Class(Tw.Input)
                        .Id("storage-input")
                        .Placeholder("Value to persist")
                        .OnInput(v => _input = v),
                    Button.Type("button").Class(Tw.BtnPrimary).Id("storage-set").OnClickAsync(Set)["Set"],
                    Button.Type("button").Class(Tw.BtnOutlinePrimary).Id("storage-read").OnClickAsync(Read)["Read"],
                    Button.Type("button").Class(Tw.BtnOutlineDanger).Id("storage-remove").OnClickAsync(Remove)["Remove"]
                ],
                Div.Class("text-sm text-ui-muted")["Last read: ", Code.Id("storage-read-value")[_read ?? "(null)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("storage-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Set()
    {
        try
        {
            await storage.Local.SetAsync(StorageKey, _input);
            _status = $"Stored: {_input}";
        }
        catch (Exception ex) { _status = "Set failed: " + ex.Message; }
    }

    private async Task Read()
    {
        try
        {
            _read = await storage.Local.GetAsync(StorageKey);
            var count = await storage.Local.LengthAsync();
            _status = $"Read (localStorage holds {count} key(s))";
        }
        catch (Exception ex) { _status = "Read failed: " + ex.Message; }
    }

    private async Task Remove()
    {
        try
        {
            await storage.Local.RemoveAsync(StorageKey);
            _read = null;
            _status = "Removed";
        }
        catch (Exception ex) { _status = "Remove failed: " + ex.Message; }
    }
}
Live result
Last read: (null)
Status: (idle)

IIndexedDb — a persistent, asynchronous key/value store, far larger than localStorage and non-blocking. Holds text (SetAsync/GetAsync) or raw bytes (SetBytesAsync/GetBytesAsync, stored as a real Uint8Array).

IndexedDbDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IIndexedDb" /> — a persistent, async key/value store backed by IndexedDB (larger than
///     localStorage). Set a value, read it back, and list keys; the data survives a reload.
/// </summary>
public sealed partial class IndexedDbDemo(IIndexedDb indexedDb) : Component
{
    private IKeyValueStore? _store;
    private string _key = "greeting";
    private string _value = "hello from IndexedDB";
    private string? _read;
    private string? _keys;
    private string? _status;

    private async Task<IKeyValueStore> StoreAsync() => _store ??= await indexedDb.OpenStoreAsync("rask-demo");

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("grid grid-cols-12 gap-4 mb-2")[
                    Div.Class("sm:col-span-4")[
                        Input
                            .Value(_key)
                            .Id("idb-key")
                            .Class(Tw.Input)
                            .OnInput(v => _key = v)],
                    Div.Class("sm:col-span-8")[
                        Input
                            .Value(_value)
                            .Id("idb-value")
                            .Class(Tw.Input)
                            .OnInput(v => _value = v)]
                ],
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Button.Class(Tw.BtnPrimary).Id("idb-set").OnClickAsync(Set)["Set"],
                    Button.Class(Tw.BtnOutlinePrimary).Id("idb-get").OnClickAsync(Get)["Get"],
                    Button.Class(Tw.BtnOutlineSecondary).Id("idb-keys").OnClickAsync(Keys)["List keys"],
                    Button.Class(Tw.BtnOutlineDanger).Id("idb-clear").OnClickAsync(Clear)["Clear"]
                ],
                Div.Class("text-sm text-ui-muted")["Read: ", Code.Id("idb-read")[_read ?? "(none)"]],
                Div.Class("text-sm text-ui-muted")["Keys: ", Code.Id("idb-keys-value")[_keys ?? "(none)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("idb-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Set()
    {
        try { await (await StoreAsync()).SetAsync(_key, _value); _status = "Stored"; }
        catch (Exception ex) { _status = "Failed: " + ex.Message; }
    }

    private async Task Get()
    {
        try { _read = await (await StoreAsync()).GetAsync(_key) ?? "(not found)"; _status = "Read"; }
        catch (Exception ex) { _status = "Failed: " + ex.Message; }
    }

    private async Task Keys()
    {
        try { _keys = string.Join(", ", await (await StoreAsync()).KeysAsync()); _status = "Listed"; }
        catch (Exception ex) { _status = "Failed: " + ex.Message; }
    }

    private async Task Clear()
    {
        try { await (await StoreAsync()).ClearAsync(); _read = _keys = null; _status = "Cleared"; }
        catch (Exception ex) { _status = "Failed: " + ex.Message; }
    }
}
Live result
Read: (none)
Keys: (none)
Status: (idle)

ICookies — read/write non-HttpOnly cookies with typed CookieOptions.

CookiesDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="ICookies" /> — read/write non-<c>HttpOnly</c> cookies via <c>document.cookie</c>,
///     identical on Server and WASM.
/// </summary>
public sealed partial class CookiesDemo(ICookies cookies) : Component
{
    private const string Name = "rask_browser_cookie";

    private string _input = "vanilla";
    private string? _read;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class($"{Tw.InputGroup} mb-2")[
                    Input
                        .Value(_input)
                        .Id("cookie-input")
                        .Class(Tw.Input)
                        .Placeholder("Cookie value")
                        .OnInput(v => _input = v),
                    Button.Type("button").Class(Tw.BtnPrimary).Id("cookie-set").OnClickAsync(Set)["Set"],
                    Button.Type("button").Class(Tw.BtnOutlinePrimary).Id("cookie-get").OnClickAsync(Get)["Get"],
                    Button.Type("button").Class(Tw.BtnOutlineDanger).Id("cookie-delete").OnClickAsync(Delete)["Delete"]
                ],
                Div.Class("text-sm text-ui-muted")["Value: ", Code.Id("cookie-read-value")[_read ?? "(null)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("cookie-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Set()
    {
        try
        {
            await cookies.SetAsync(Name, _input, new CookieOptions
            {
                MaxAgeSeconds = 3600,
                Path = "/",
                SameSite = SameSiteMode.Lax
            });
            _status = $"Set: {_input}";
        }
        catch (Exception ex) { _status = "Set failed: " + ex.Message; }
    }

    private async Task Get()
    {
        try
        {
            _read = await cookies.GetAsync(Name);
            _status = _read is null ? "Not present" : "Read";
        }
        catch (Exception ex) { _status = "Get failed: " + ex.Message; }
    }

    private async Task Delete()
    {
        try
        {
            await cookies.DeleteAsync(Name, "/");
            _read = null;
            _status = "Deleted";
        }
        catch (Exception ex) { _status = "Delete failed: " + ex.Message; }
    }
}
Live result
Value: (null)
Status: (idle)

IStorageEstimator — the origin's storage quota and usage, to budget a cache.

StorageEstimateDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary><see cref="IStorageEstimator" /> — read the origin's storage quota and usage.</summary>
public sealed partial class StorageEstimateDemo(IStorageEstimator storage) : Component
{
    private string? _value;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Button.Class($"{Tw.BtnOutlinePrimary} mb-2").Type("button")
                    .Id("storage-est-read")
                    .OnClickAsync(Read)[
                    "Estimate storage"],
                Div.Class("text-sm text-ui-muted")["Budget: ", Code.Id("storage-est-value")[_value ?? "(not requested)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("storage-est-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Read()
    {
        try
        {
            if (!await storage.IsSupportedAsync())
            {
                _value = "not supported in this browser";
                _status = "Storage estimate unavailable";
                return;
            }

            var e = await storage.EstimateAsync();
            _value = e is null
                ? "unavailable"
                : $"{Mb(e.Usage)} / {Mb(e.Quota)} MB used ({e.UsageRatio:P1})";
            _status = "Estimate read";
        }
        catch (Exception ex) { _status = "Read failed: " + ex.Message; }
    }

    private static string Mb(long bytes) => (bytes / 1024.0 / 1024.0).ToString("N1");
}
Live result
Budget: (not requested)
Status: (idle)

Environment & capabilities

INavigatorInfo — read-only navigator facts: onLine, language, userAgent.

NavigatorInfoDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary><see cref="INavigatorInfo" /> — read-only navigator facts (online, language, user agent).</summary>
public sealed partial class NavigatorInfoDemo(INavigatorInfo navigator) : Component
{
    private string? _value;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Button.Class($"{Tw.BtnOutlinePrimary} mb-2").Type("button")
                    .Id("nav-read")
                    .OnClickAsync(Read)[
                    "Read navigator info"],
                Div.Class("text-sm text-ui-muted")["Info: ", Code.Id("nav-value")[_value ?? "(not requested)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("nav-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Read()
    {
        try
        {
            var online = await navigator.OnLineAsync();
            var language = await navigator.LanguageAsync();
            _value = $"online: {online}, language: {language}";
            _status = "Navigator read";
        }
        catch (Exception ex) { _status = "Read failed: " + ex.Message; }
    }
}
Live result
Info: (not requested)
Status: (idle)

INetworkInfo — connection quality (effective type, downlink, RTT, Data Saver) to adapt loading.

NetworkInfoDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary><see cref="INetworkInfo" /> — read the connection quality (effective type, downlink, Data Saver).</summary>
public sealed partial class NetworkInfoDemo(INetworkInfo network) : Component
{
    private string? _value;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Button.Class($"{Tw.BtnOutlinePrimary} mb-2").Type("button")
                    .Id("net-read")
                    .OnClickAsync(Read)[
                    "Read network status"],
                Div.Class("text-sm text-ui-muted")["Connection: ", Code.Id("net-value")[_value ?? "(not requested)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("net-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Read()
    {
        try
        {
            if (!await network.IsSupportedAsync())
            {
                _value = "not supported (try a Chromium browser)";
                _status = "Network Information unavailable";
                return;
            }

            var status = await network.GetStatusAsync();
            _value = status is null
                ? "unavailable"
                : $"{status.EffectiveType}, {status.Downlink} Mbps, {status.Rtt} ms RTT, saveData: {status.SaveData}";
            _status = "Network read";
        }
        catch (Exception ex) { _status = "Read failed: " + ex.Message; }
    }
}
Live result
Connection: (not requested)
Status: (idle)
BatteryDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IBattery" /> — read the device charge level and charging state, and subscribe to changes.
///     The watch is opened on mount and disposed on unmount; its handler updates state and calls
///     <c>StateHasChanged()</c> (the sanctioned pattern for an externally-pushed update). Browser support is
///     Chromium-only, so each call is gated on <see cref="IBattery.IsSupportedAsync" />.
/// </summary>
public sealed partial class BatteryDemo(IBattery battery) : Component, IAsyncDisposable
{
    private BatteryStatus? _status;

    // Two labels, not one, because the two halves of this demo write on their own schedules: the watch
    // pushes whenever the device changes, and the button reports what a one-shot read just returned.
    // Sharing a field made whichever wrote last the visible truth — a push landing after a click replaced
    // "read" with "live" and never put it back, which read as the button having done nothing.
    private string _watchState = "(starting…)";
    private string _readState = "(not read yet)";

    // The level/charging figures ARE shared on purpose: both sources describe the same battery, so the
    // freshest value is the right one to show whichever produced it.
    private IAsyncDisposable? _watch;
    private bool _started;

    protected override async Task OnRenderedAsync(bool firstRender)
    {
        if (!firstRender || _started)
        {
            return;
        }

        _started = true;
        if (!await battery.IsSupportedAsync())
        {
            _watchState = "not supported on this browser";
            _readState = "not supported on this browser";
            StateHasChanged();
            return;
        }

        _watch = await battery.WatchAsync(s =>
        {
            _status = s;
            _watchState = "live";
            StateHasChanged();
            return Task.CompletedTask;
        });
    }

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Button.Type("button").Class(Tw.BtnPrimary).Id("battery-read").OnClickAsync(Read)[
                        "Read now"]
                ],
                Div.Class("text-sm text-ui-muted mb-1")[
                    "Level: ", Code.Id("battery-level")[_status is { } s ? $"{s.Level * 100:0}%" : "(none)"]],
                Div.Class("text-sm text-ui-muted mb-1")[
                    "Charging: ", Code.Id("battery-charging")[_status is { } c ? (c.Charging ? "yes" : "no") : "(none)"]],
                Div.Class("text-sm text-ui-muted mb-1")[
                    "Watch: ", Code.Id("battery-watch")[_watchState]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("battery-status")[_readState]]
            ]
        ];

    private async Task Read()
    {
        try
        {
            _status = await battery.GetStatusAsync();
            _readState = _status is null ? "not supported" : "read";
        }
        catch (Exception ex)
        {
            _readState = "failed: " + ex.Message;
        }
    }

    public async ValueTask DisposeAsync()
    {
        if (_watch is not null)
        {
            await _watch.DisposeAsync();
        }
    }
}
Live result
Level: (none)
Charging: (none)
Watch: (starting…)
Status: (not read yet)

IScreenInfo — display size, colour depth, and device pixel ratio.

ScreenInfoDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary><see cref="IScreenInfo" /> — read the display size, color depth, and device pixel ratio.</summary>
public sealed partial class ScreenInfoDemo(IScreenInfo screen) : Component
{
    private string? _value;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Button.Class($"{Tw.BtnOutlinePrimary} mb-2").Type("button")
                    .Id("screen-read")
                    .OnClickAsync(Read)[
                    "Read screen info"],
                Div.Class("text-sm text-ui-muted")["Display: ", Code.Id("screen-value")[_value ?? "(not requested)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("screen-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Read()
    {
        try
        {
            var s = await screen.GetAsync();
            _value = $"{s.Width}×{s.Height} (avail {s.AvailWidth}×{s.AvailHeight}), {s.ColorDepth}-bit, DPR {s.PixelRatio}";
            _status = "Screen read";
        }
        catch (Exception ex) { _status = "Read failed: " + ex.Message; }
    }
}
Live result
Display: (not requested)
Status: (idle)

IVisualViewport — the actually-visible viewport: size, offset, and pinch-zoom scale.

VisualViewportDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary><see cref="IVisualViewport" /> — read the actually-visible viewport (size, offset, zoom).</summary>
public sealed partial class VisualViewportDemo(IVisualViewport viewport) : Component
{
    private string? _value;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Button.Class($"{Tw.BtnOutlinePrimary} mb-2").Type("button")
                    .Id("vv-read")
                    .OnClickAsync(Read)[
                    "Read visual viewport"],
                Div.Class("text-sm text-ui-muted")["Viewport: ", Code.Id("vv-value")[_value ?? "(not requested)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("vv-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Read()
    {
        try
        {
            if (!await viewport.IsSupportedAsync())
            {
                _value = "not supported in this browser";
                _status = "Visual viewport unavailable";
                return;
            }

            var v = await viewport.GetAsync();
            _value = v is null
                ? "unavailable"
                : $"{v.Width:N0}×{v.Height:N0} @ scale {v.Scale:N2}, offset ({v.OffsetLeft:N0}, {v.OffsetTop:N0})";
            _status = "Viewport read";
        }
        catch (Exception ex) { _status = "Read failed: " + ex.Message; }
    }
}
Live result
Viewport: (not requested)
Status: (idle)

IMediaQuery — evaluate CSS media queries and preferences (dark mode, reduced motion) from C#.

MediaQueryDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary><see cref="IMediaQuery" /> — evaluate CSS media queries and user preferences from C#.</summary>
public sealed partial class MediaQueryDemo(IMediaQuery media) : Component
{
    private string? _value;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Button.Class($"{Tw.BtnOutlinePrimary} mb-2").Type("button")
                    .Id("media-read")
                    .OnClickAsync(Read)[
                    "Evaluate media queries"],
                Div.Class("text-sm text-ui-muted")["Result: ", Code.Id("media-value")[_value ?? "(not requested)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("media-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Read()
    {
        try
        {
            var wide = await media.MatchesAsync("(min-width: 768px)");
            var dark = await media.PrefersDarkAsync();
            var reduced = await media.PrefersReducedMotionAsync();
            _value = $"≥768px: {wide}, prefersDark: {dark}, reducedMotion: {reduced}";
            _status = "Media queries evaluated";
        }
        catch (Exception ex) { _status = "Read failed: " + ex.Message; }
    }
}
Live result
Result: (not requested)
Status: (idle)

IPageVisibility — whether the page is foreground/visible.

PageVisibilityDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IPageVisibility" /> — read whether the page is foreground/visible, e.g. to pause work
///     when the user tabs away.
/// </summary>
public sealed partial class PageVisibilityDemo(IPageVisibility visibility) : Component
{
    private string? _state;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Button.Class($"{Tw.BtnOutlinePrimary} mb-2").Type("button")
                    .Id("vis-read")
                    .OnClickAsync(Read)[
                    "Read visibility"],
                Div.Class("text-sm text-ui-muted")["State: ", Code.Id("vis-value")[_state ?? "(not read)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("vis-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Read()
    {
        try
        {
            var state = await visibility.GetStateAsync();
            var hidden = await visibility.IsHiddenAsync();
            _state = $"{state} (hidden: {hidden})";
            _status = "Read";
        }
        catch (Exception ex) { _status = "Read failed: " + ex.Message; }
    }
}
Live result
State: (not read)
Status: (idle)

IViewTransitions — animate between the old and new DOM instead of the new one just appearing.

The one wrapper here you could not have written yourself. A same-document transition has to wrap the DOM mutation, and the mutation is the framework's morph — there is no point in your code that sits around it. Enabling routes the live runtime's own commit (diff apply and full-document apply, on both hosts) through document.startViewTransition.

Off by default, and off is exactly the previous behaviour: the commit stays synchronous. Style it with the standard ::view-transition-* pseudo-elements; give an element a stable view-transition-name and the browser morphs it between routes rather than cross-fading it, which is what makes a shared header travel. prefers-reduced-motion is honoured for you — the animation is the browser's own default, so there is no stylesheet of yours for the preference to switch off.

IsActiveAsync() is deliberately separate from what you set: a toggle can be on while nothing animates because the browser lacks the API or the reader asked for less motion.


await _viewTransitions.SetEnabledAsync(true);

IWebAnimations — run and control an animation on an element from C#, no stylesheet and no animation library.

Keyframes use the API's object form — a property name to the values it moves through — which is what Element.animate() takes natively:


var id = await _anim.StartAsync(_card, new Dictionary<string, string[]>
{
    ["opacity"] = ["0", "1"],
    ["transform"] = ["translateY(8px)", "none"],
}, new AnimationOptions(DurationMs: 200, Easing: "ease-out", Fill: "forwards"));

await _anim.WaitAsync(id);   // true if it finished, false if it was cancelled — never throws

StartAsync returns a handle (AnimationId) because an Animation object cannot cross interop — the same shape MediaStreamId uses. On a browser without the API the handle is simply invalid rather than an error, so you can animate without feature-testing first. Iterations: -1 means forever (JSON has no Infinity literal). Cancel/Finish/Pause/Play are all harmless on a handle that has already finished.

Unlike IViewTransitions, reduced motion is yours to decide here — these are your animations, and only you know whether a given one is a loading affordance or decoration. Read the preference with IMediaQuery and skip what should be skipped.

IPerformance — a high-resolution monotonic clock and page-load (Navigation Timing) metrics.

PerformanceDemo.cs

using System.Globalization;
using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary><see cref="IPerformance" /> — high-resolution clock and page-load (navigation) timing.</summary>
public sealed partial class PerformanceDemo(IPerformance performance) : Component
{
    private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
    private string? _value;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Button.Class($"{Tw.BtnOutlinePrimary} mb-2").Id("perf-read").OnClickAsync(Read)[
                    "Read performance timing"],
                Div.Class("text-sm text-ui-muted")["Timing: ", Code.Id("perf-value")[_value ?? "(not requested)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("perf-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Read()
    {
        try
        {
            var now = await performance.NowAsync();
            var t = await performance.GetNavigationTimingAsync();
            _value = t is null
                ? string.Create(Inv, $"now {now:F0} ms (no navigation entry)")
                : string.Create(Inv,
                    $"TTFB {t.TimeToFirstByteMs:F0} ms, DOMContentLoaded {t.DomContentLoadedMs:F0} ms, load {t.LoadMs:F0} ms");
            _status = "Performance read";
        }
        catch (Exception ex) { _status = "Read failed: " + ex.Message; }
    }
}
Live result
Timing: (not requested)
Status: (idle)

IPermissions — check a feature's permission state before triggering a prompt.

PermissionsDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IPermissions" /> — query a feature's permission state (granted/denied/prompt) before
///     triggering it. Pairs with <see cref="IGeolocation" /> / <see cref="IClipboard" />.
/// </summary>
public sealed partial class PermissionsDemo(IPermissions permissions) : Component
{
    private string? _geo;
    private string? _clip;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Button.Type("button").Class(Tw.BtnOutlinePrimary)
                        .Id("perm-geo")
                        .OnClickAsync(QueryGeo)[
                        "Query geolocation"],
                    Button.Type("button").Class(Tw.BtnOutlinePrimary)
                        .Id("perm-clip")
                        .OnClickAsync(QueryClipboard)[
                        "Query clipboard-read"]
                ],
                Div.Class("text-sm text-ui-muted")["geolocation: ", Code.Id("perm-geo-value")[_geo ?? "(unknown)"]],
                Div.Class("text-sm text-ui-muted")["clipboard-read: ", Code.Id("perm-clip-value")[_clip ?? "(unknown)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("perm-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task QueryGeo()
    {
        try
        {
            _geo = (await permissions.QueryAsync(PermissionName.Geolocation)).ToString();
            _status = "Queried geolocation";
        }
        catch (Exception ex) { _status = "Query failed: " + ex.Message; }
    }

    private async Task QueryClipboard()
    {
        try
        {
            _clip = (await permissions.QueryAsync(PermissionName.ClipboardRead)).ToString();
            _status = "Queried clipboard-read";
        }
        catch (Exception ex) { _status = "Query failed: " + ex.Message; }
    }
}
Live result
geolocation: (unknown)
clipboard-read: (unknown)
Status: (idle)

Location, sensors & input

IGeolocation — one-shot device position.

GeolocationDemo.cs

using System.Globalization;
using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary><see cref="IGeolocation" /> — one-shot current position via the Promise-wrapped helper.</summary>
public sealed partial class GeolocationDemo(IGeolocation geolocation) : Component
{
    private string? _location;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Button.Class($"{Tw.BtnOutlinePrimary} mb-2").Type("button")
                    .Id("geo-get")
                    .OnClickAsync(Get)[
                    "Get current position"],
                Div.Class("text-sm text-ui-muted")["Position: ", Code.Id("geo-value")[_location ?? "(not requested)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("geo-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Get()
    {
        try
        {
            var pos = await geolocation.GetCurrentPositionAsync(new GeolocationOptions { TimeoutMs = 10_000 });
            // Coordinates format invariantly (decimal point) — independent of the server's locale.
            _location = string.Create(
                CultureInfo.InvariantCulture,
                $"lat {pos.Latitude:F4}, lon {pos.Longitude:F4} (±{pos.Accuracy:F0} m)");
            _status = "Position acquired";
        }
        catch (Exception ex)
        {
            _location = null;
            _status = "Location failed: " + ex.Message;
        }
    }
}
Live result
Position: (not requested)
Status: (idle)

IGeolocation.WatchAsync — track position live; the browser pushes each fix to C#.

GeolocationWatchDemo.cs

using System.Globalization;
using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IGeolocation.WatchAsync" /> — live position tracking. Start watching and the browser
///     pushes each fix to C#, which re-renders the readout (the handler calls <c>StateHasChanged()</c>,
///     the sanctioned pushed-update pattern). Stop disposes the watch (<c>clearWatch</c>).
/// </summary>
public sealed partial class GeolocationWatchDemo(IGeolocation geolocation) : Component, IAsyncDisposable
{
    private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
    private IAsyncDisposable? _watch;
    private string? _location;
    private int _fixes;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    _watch is null
                        ? Button.Class(Tw.BtnPrimary).Id("geowatch-start").OnClickAsync(Start)[
                            "Start watching"]
                        : Button.Class(Tw.BtnOutlineDanger).Id("geowatch-stop").OnClickAsync(Stop)[
                            "Stop"]
                ],
                Div.Class("text-sm text-ui-muted")[
                    "Position: ", Code.Id("geowatch-value")[_location ?? "(not watching)"],
                    Span.Class("ms-2").Id("geowatch-fixes")[$"({_fixes} fix(es))"]
                ],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("geowatch-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Start()
    {
        try
        {
            _watch = await geolocation.WatchAsync(pos =>
            {
                _fixes++;
                _location = string.Create(Inv, $"lat {pos.Latitude:F4}, lon {pos.Longitude:F4} (±{pos.Accuracy:F0} m)");
                StateHasChanged();
                return Task.CompletedTask;
            }, new GeolocationOptions { EnableHighAccuracy = true });
            _status = "Watching — move the device to see updates";
        }
        catch (Exception ex)
        {
            _status = "Watch failed: " + ex.Message;
        }
    }

    private async Task Stop()
    {
        if (_watch is not null)
        {
            await _watch.DisposeAsync();
            _watch = null;
        }

        _status = "Stopped";
    }

    public async ValueTask DisposeAsync()
    {
        if (_watch is not null)
        {
            await _watch.DisposeAsync();
        }
    }
}
Live result
Position: (not watching)(0 fix(es))
Status: (idle)

IDeviceOrientation / IDeviceMotion — gyroscope/compass and accelerometer readings.

DeviceSensorsDemo.cs

using Rask.Core;
using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IDeviceOrientation" /> + <see cref="IDeviceMotion" /> — read the gyroscope/compass tilt
///     and the accelerometer. Tap <b>Start</b> (which requests sensor permission from the gesture, required
///     on iOS), then tilt or shake the device: the browser pushes each reading to C#, which updates the
///     readout (the handler calls <c>StateHasChanged()</c>, the sanctioned pattern for an externally-pushed
///     update). Sensors only emit on a real device with motion hardware.
/// </summary>
public sealed partial class DeviceSensorsDemo(IDeviceOrientation orientation, IDeviceMotion motion)
    : Component, IAsyncDisposable
{
    private IAsyncDisposable? _orientationWatch;
    private IAsyncDisposable? _motionWatch;
    private string _status = "(idle)";
    private OrientationReading? _tilt;
    private MotionReading? _accel;

    private async Task Start()
    {
        try
        {
            if (!await orientation.IsSupportedAsync())
            {
                _status = "Device orientation not supported";
                return;
            }

            // Request both permissions up front, before any WatchAsync — iOS only honours
            // requestPermission() while the click's user activation is still live, so the motion request
            // must not wait behind the orientation watch.
            var orientationGranted = await orientation.RequestPermissionAsync() == SensorPermission.Granted;
            var motionGranted = await motion.RequestPermissionAsync() == SensorPermission.Granted;

            if (!orientationGranted)
            {
                _status = "Permission denied";
                return;
            }

            _orientationWatch ??= await orientation.WatchAsync(r =>
            {
                _tilt = r;
                StateHasChanged();
                return Task.CompletedTask;
            });

            if (motionGranted)
            {
                _motionWatch ??= await motion.WatchAsync(r =>
                {
                    _accel = r;
                    StateHasChanged();
                    return Task.CompletedTask;
                });
            }

            _status = "listening — tilt or shake the device";
        }
        catch (Exception ex)
        {
            _status = "start failed: " + ex.Message;
        }
    }

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Button.Class($"{Tw.BtnPrimary} mb-3").Id("sensor-start").OnClickAsync(Start)["Start"],
                Div.Class("text-sm text-ui-muted mb-2")["Status: ", Code.Id("sensor-status")[_status]],
                Div.Class("grid grid-cols-12 gap-4")[
                    Div.Class("sm:col-span-6")[
                        Div.Class("font-semibold text-sm mb-1")["Orientation (°)"],
                        Div.Class("text-sm text-ui-muted")[
                            "α ", Code.Id("sensor-alpha")[Fmt(_tilt?.Alpha)],
                            " · β ", Code.Id("sensor-beta")[Fmt(_tilt?.Beta)],
                            " · γ ", Code.Id("sensor-gamma")[Fmt(_tilt?.Gamma)]]
                    ],
                    Div.Class("sm:col-span-6")[
                        Div.Class("font-semibold text-sm mb-1")["Acceleration (m/s²)"],
                        Div.Class("text-sm text-ui-muted")[
                            "x ", Code.Id("sensor-ax")[Fmt(_accel?.AccelerationX)],
                            " · y ", Code.Id("sensor-ay")[Fmt(_accel?.AccelerationY)],
                            " · z ", Code.Id("sensor-az")[Fmt(_accel?.AccelerationZ)]]
                    ]
                ]
            ]
        ];

    private static string Fmt(double? value) => value is null ? "—" : value.Value.ToString("0.0");

    public async ValueTask DisposeAsync()
    {
        if (_orientationWatch is not null)
        {
            await _orientationWatch.DisposeAsync();
        }

        if (_motionWatch is not null)
        {
            await _motionWatch.DisposeAsync();
        }
    }
}
Live result
Status: (idle)
Orientation (°)
α · β · γ
Acceleration (m/s²)
x · y · z

IGamepad — connected game controllers (sticks, triggers, buttons); prefer WASM for twitch input.

GamepadDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IGamepad" /> — poll connected game controllers and react to stick/button input. The
///     framework runs the <c>requestAnimationFrame</c> poll and pushes a reading only when a pad's state
///     changes; this demo keeps the latest reading per connected pad.
/// </summary>
public sealed partial class GamepadDemo(IGamepad gamepad) : Component, IAsyncDisposable
{
    private readonly Dictionary<int, GamepadReading> _pads = [];
    private IAsyncDisposable? _watch;
    private string _status = "(idle)";

    protected override async Task OnRenderedAsync(bool firstRender)
    {
        if (!firstRender || _watch is not null)
        {
            return;
        }

        if (!await gamepad.IsSupportedAsync())
        {
            _status = "Gamepad API not supported";
            StateHasChanged();
            return;
        }

        _status = "Ready — connect a controller and press a button";
        _watch = await gamepad.WatchAsync(reading =>
        {
            if (reading.Connected)
            {
                _pads[reading.Index] = reading;
            }
            else
            {
                _pads.Remove(reading.Index);
            }

            StateHasChanged();
            return Task.CompletedTask;
        });
        StateHasChanged();
    }

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("text-sm text-ui-muted mb-2")["Status: ", Code.Id("gamepad-status")[_status]],
                Div.Class("text-sm text-ui-muted mb-2")[
                    "Connected pads: ", Code.Id("gamepad-count")[_pads.Count.ToString()]],
                _pads.Count == 0
                    ? Div.Class("text-ui-muted text-sm")["No controllers connected."]
                    : Ul.Class($"{Tw.ListGroup} divide-y divide-ui-line")[
                        _pads.Values.Select(p => (Component)Li.Class($"{Tw.ListGroupItem} px-0").Key(p.Index)[
                            Div.Class("text-sm font-semibold")[$"#{p.Index} — {p.Id}"],
                            Div.Class("text-sm text-ui-muted")[
                                $"axes [{string.Join(", ", p.Axes.Select(a => a.ToString("0.00")))}] · "
                                + $"buttons pressed {p.Buttons.Count(b => b > 0.5)}/{p.Buttons.Count}"]
                        ])
                    ]
            ]
        ];

    public async ValueTask DisposeAsync()
    {
        if (_watch is not null)
        {
            await _watch.DisposeAsync();
        }
    }
}
Live result
Status: (idle)
Connected pads: 0
No controllers connected.

IVibration — pulse the device's vibration motor (mobile).

VibrationDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary><see cref="IVibration" /> — pulse the device's vibration motor (effective on mobile).</summary>
public sealed partial class VibrationDemo(IVibration vibration) : Component
{
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Button.Type("button").Class(Tw.BtnOutlinePrimary)
                        .Id("vibrate-buzz")
                        .OnClickAsync(Buzz)["Buzz"],
                    Button.Type("button").Class(Tw.BtnOutlinePrimary)
                        .Id("vibrate-pattern")
                        .OnClickAsync(Pattern)[
                        "Pattern"],
                    Button.Type("button").Class(Tw.BtnOutlineDanger)
                        .Id("vibrate-cancel")
                        .OnClickAsync(Cancel)["Cancel"]
                ],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("vibrate-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Buzz()
    {
        var ok = await vibration.VibrateAsync(200);
        _status = ok ? "Vibrated" : "Not supported on this device";
    }

    private async Task Pattern()
    {
        var ok = await vibration.VibrateAsync(100, 50, 100, 50, 300);
        _status = ok ? "Pattern played" : "Not supported on this device";
    }

    private async Task Cancel()
    {
        await vibration.CancelAsync();
        _status = "Cancelled";
    }
}
Live result
Status: (idle)

Observers

The push pattern above, one element at a time.

IIntersectionObserver — notified when an element enters or leaves the viewport.

IntersectionObserverDemo.cs

using Rask.Core;
using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IIntersectionObserver" /> — observe when an element enters/leaves the viewport. Scroll
///     the box below into view: the browser pushes the change to C#, which updates the badge (the handler
///     calls <c>StateHasChanged()</c>, the sanctioned pattern for an externally-pushed update).
/// </summary>
public sealed partial class IntersectionObserverDemo(IIntersectionObserver observer) : Component, IAsyncDisposable
{
    private readonly ElementRef _target = ElementRef.New();
    private IAsyncDisposable? _observation;
    private bool _visible;
    private int _changes;

    protected override async Task OnRenderedAsync(bool firstRender)
    {
        if (!firstRender || _observation is not null)
        {
            return;
        }

        _observation = await observer.ObserveAsync(_target, entry =>
        {
            _visible = entry.IsIntersecting;
            _changes++;
            StateHasChanged();
            return Task.CompletedTask;
        });
    }

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 items-center flex-wrap mb-2")[
                    Span.Class(_visible ? $"{Tw.BadgeSuccess}" : $"{Tw.BadgeSecondary}").Id("io-status")[
                        _visible ? "in view" : "out of view"],
                    Span.Class("text-sm text-ui-muted").Id("io-changes")[$"{_changes} change(s)"]
                ],
                P.Class("text-sm text-ui-muted mb-2")["Scroll down — the target reports when it enters the viewport."],
                // A tall spacer so the target starts below the fold, then the observed target.
                Div.Style("height: 130vh"),
                Div
                    .Ref(_target)
                    .Id("io-target")
                    .Class("p-4 rounded text-center " + (_visible ? "bg-success-subtle" : "bg-ui-well"))[
                    "🎯 observed target"
                ]
            ]
        ];

    public async ValueTask DisposeAsync()
    {
        if (_observation is not null)
        {
            await _observation.DisposeAsync();
        }
    }
}
Live result
out of view0 change(s)

Scroll down — the target reports when it enters the viewport.

🎯 observed target

IResizeObserver — notified when an element's size changes.

ResizeObserverDemo.cs

using System.Globalization;
using Rask.Core;
using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IResizeObserver" /> — report an element's size as it changes. The box below is observed;
///     toggle its width (or resize the window) and the browser pushes the new size to C#, which re-renders
///     the readout (the handler calls <c>StateHasChanged()</c>, the sanctioned pushed-update pattern).
/// </summary>
public sealed partial class ResizeObserverDemo(IResizeObserver observer) : Component, IAsyncDisposable
{
    private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
    private readonly ElementRef _box = ElementRef.New();
    private IAsyncDisposable? _observation;
    private double _width;
    private double _height;
    private bool _wide = true;

    protected override async Task OnRenderedAsync(bool firstRender)
    {
        if (!firstRender || _observation is not null)
        {
            return;
        }

        _observation = await observer.ObserveAsync(_box, size =>
        {
            _width = size.Width;
            _height = size.Height;
            StateHasChanged();
            return Task.CompletedTask;
        });
    }

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("text-sm text-ui-muted mb-2")[
                    "Observed size: ",
                    Code.Id("resize-value")[
                        _width > 0 ? $"{_width.ToString("0", Inv)} × {_height.ToString("0", Inv)} px" : "(measuring…)"]
                ],
                Button
                    .Class($"{Tw.BtnOutlinePrimary} mb-2")
                    .Id("resize-toggle")
                    .OnClick(() => _wide = !_wide)["Toggle width"],
                Div
                    .Ref(_box)
                    .Id("resize-box")
                    .Class((_wide ? "w-full" : "w-1/2") + "p-4 rounded bg-ui-well text-center")[
                    "📐 observed box (resize the window too)"
                ]
            ]
        ];

    public async ValueTask DisposeAsync()
    {
        if (_observation is not null)
        {
            await _observation.DisposeAsync();
        }
    }
}
Live result
Observed size: (measuring…)
📐 observed box (resize the window too)

IMutationObserver — notified when an element's children, attributes, or text change.

MutationObserverDemo.cs

using Rask.Core;
using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IMutationObserver" /> — observe DOM changes (children, attributes, text) on an element.
///     Mutate the watched box with the buttons: the browser pushes each <c>MutationRecord</c> to C#, which
///     updates the tally (the handler calls <c>StateHasChanged()</c>, the sanctioned pattern for an
///     externally-pushed update).
/// </summary>
public sealed partial class MutationObserverDemo(IMutationObserver observer) : Component, IAsyncDisposable
{
    private readonly ElementRef _target = ElementRef.New();
    private IAsyncDisposable? _observation;
    private int _items = 1;
    private bool _highlight;
    private int _childChanges;
    private int _attrChanges;
    private string _last = "(none yet)";

    protected override async Task OnRenderedAsync(bool firstRender)
    {
        if (!firstRender || _observation is not null)
        {
            return;
        }

        _observation = await observer.ObserveAsync(_target, entry =>
        {
            if (entry.Type == "attributes")
            {
                _attrChanges++;
            }
            else
            {
                _childChanges++;
            }

            _last = entry.Type == "attributes"
                ? $"attributes ({entry.AttributeName})"
                : $"childList (+{entry.AddedCount} / -{entry.RemovedCount})";
            StateHasChanged();
            return Task.CompletedTask;
        }, new MutationOptions { ChildList = true, Attributes = true, Subtree = true });
    }

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-3")[
                    Button.Class(Tw.BtnPrimary).Id("mo-add").OnClick(() => _items++)["Add item"],
                    Button
                        .Class(Tw.BtnOutlinePrimary)
                        .Id("mo-remove")
                        .OnClick(() => { if (_items > 0) _items--; })["Remove item"],
                    Button
                        .Class(Tw.BtnOutlineSecondary)
                        .Id("mo-toggle")
                        .OnClick(() => _highlight = !_highlight)["Toggle attribute"]
                ],
                Div
                    .Ref(_target)
                    .Id("mo-target")
                    .Class("border rounded p-3 mb-3" + (_highlight ? " border-warning bg-warning-subtle" : ""))[
                    Ul.Class("mb-0")[
                        Enumerable.Range(1, _items).Select(i => Li.Key(i.ToString())[$"item {i}"])
                    ]
                ],
                Div.Class("text-sm text-ui-muted")[
                    "childList changes: ", Code.Id("mo-child")[$"{_childChanges}"],
                    " · attribute changes: ", Code.Id("mo-attr")[$"{_attrChanges}"]
                ],
                Div.Class("text-sm text-ui-muted")["Last: ", Code.Id("mo-last")[_last]]
            ]
        ];

    public async ValueTask DisposeAsync()
    {
        if (_observation is not null)
        {
            await _observation.DisposeAsync();
        }
    }
}
Live result
  • item 1
childList changes: 0 · attribute changes: 0
Last: (none yet)

Media, crypto & files

IClipboard — copy to and read from the system clipboard.

ClipboardDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary><see cref="IClipboard" /> — copy to and read back from the system clipboard.</summary>
public sealed partial class ClipboardDemo(IClipboard clipboard) : Component
{
    private string _input = "Copied from Rask!";
    private string? _read;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("mb-2 flex gap-2")[
                    Input.Value(_input).Class(Tw.Input).Id("clipboard-input").OnInput(v => _input = v),
                    Button.Type("button").Class(Tw.BtnPrimary).Id("clipboard-copy").OnClickAsync(Copy)["Copy"],
                    Button.Type("button").Class(Tw.BtnOutlinePrimary).Id("clipboard-paste").OnClickAsync(Paste)["Paste"]
                ],
                Div.Class("text-sm text-ui-muted")["Pasted: ", Code.Id("clipboard-read-value")[_read ?? "(nothing yet)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("clipboard-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Copy()
    {
        try
        {
            await clipboard.WriteTextAsync(_input);
            _status = "Copied to clipboard";
        }
        catch (Exception ex) { _status = "Copy failed: " + ex.Message; }
    }

    private async Task Paste()
    {
        try
        {
            _read = await clipboard.ReadTextAsync();
            _status = "Pasted from clipboard";
        }
        catch (Exception ex) { _status = "Paste failed: " + ex.Message; }
    }
}
Live result
Pasted: (nothing yet)
Status: (idle)

ISpeechSynthesis — speak text aloud from C#.

SpeechDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary><see cref="ISpeechSynthesis" /> — speak text aloud (text-to-speech).</summary>
public sealed partial class SpeechDemo(ISpeechSynthesis speech) : Component
{
    private string _text = "Hello from Rask — spoken straight from C#.";
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Input
                    .Value(_text)
                    .Id("speech-text")
                    .Class($"{Tw.Input} mb-2")
                    .OnInput(v => _text = v),
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Button.Type("button").Class(Tw.BtnPrimary).Id("speech-speak").OnClickAsync(Speak)["Speak"],
                    Button.Type("button").Class(Tw.BtnOutlineDanger)
                        .Id("speech-cancel")
                        .OnClickAsync(Cancel)["Stop"]
                ],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("speech-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Speak()
    {
        try
        {
            if (!await speech.IsSupportedAsync())
            {
                _status = "Speech synthesis not supported in this browser";
                return;
            }

            await speech.SpeakAsync(_text, new SpeechOptions { Lang = "en-US", Rate = 1 });
            _status = "Speaking";
        }
        catch (Exception ex) { _status = "Failed: " + ex.Message; }
    }

    private async Task Cancel()
    {
        await speech.CancelAsync();
        _status = "Stopped";
    }
}
Live result
Status: (idle)
SpeechRecognitionDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="ISpeechRecognition" /> — dictation: start listening, and each recognised phrase is pushed
///     to the handler (final phrases accumulate; the interim hypothesis shows live). Prompts for microphone
///     access on start; browser support is Chromium-only (gate on <see cref="ISpeechRecognition.IsSupportedAsync" />).
///     The handler updates state and calls <c>StateHasChanged()</c> — the sanctioned pattern for an
///     externally-pushed update.
/// </summary>
public sealed partial class SpeechRecognitionDemo(ISpeechRecognition recognition) : Component, IAsyncDisposable
{
    private IAsyncDisposable? _session;
    private string _transcript = "";
    private string _interim = "";
    private string _status = "(idle)";

    private bool Listening => _session is not null;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Button.Type("button").Class(Tw.BtnPrimary)
                        .Id("speech-recognize-start")
                        .Disabled(Listening)
                        .OnClickAsync(Start)["Start listening"],
                    Button.Type("button").Class(Tw.BtnOutlineDanger)
                        .Id("speech-recognize-stop")
                        .Disabled(!Listening)
                        .OnClickAsync(Stop)["Stop"]
                ],
                Div.Class("text-sm text-ui-muted mb-1")[
                    "Transcript: ",
                    Code.Id("speech-recognize-transcript")[_transcript.Length == 0 ? "(none)" : _transcript],
                    _interim.Length == 0 ? (Component?)null : Span.Class("text-ui-muted italic")[" ", _interim]
                ],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("speech-recognize-status")[_status]]
            ]
        ];

    private async Task Start()
    {
        if (!await recognition.IsSupportedAsync())
        {
            _status = "not supported on this browser";
            return;
        }

        _transcript = "";
        _interim = "";
        _status = "listening…";
        try
        {
            _session = await recognition.StartAsync(
                r =>
                {
                    if (r.IsFinal)
                    {
                        _transcript = (_transcript + " " + r.Transcript).Trim();
                        _interim = "";
                    }
                    else
                    {
                        _interim = r.Transcript;
                    }

                    StateHasChanged();
                    return Task.CompletedTask;
                },
                new SpeechRecognitionOptions { Continuous = true, InterimResults = true });
        }
        catch (Exception ex)
        {
            _status = "failed: " + ex.Message;
        }
    }

    private async Task Stop()
    {
        if (_session is not null)
        {
            await _session.DisposeAsync();
            _session = null;
        }

        _interim = "";
        _status = "stopped";
    }

    public async ValueTask DisposeAsync()
    {
        if (_session is not null)
        {
            await _session.DisposeAsync();
        }
    }
}
Live result
Transcript: (none)
Status: (idle)

IMediaSession — publish now-playing metadata to the OS and handle hardware media keys.

MediaSessionDemo.cs

using Rask.Core;
using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IMediaSession" /> — publish now-playing metadata to the OS (lock screen / media hub) and
///     handle hardware media keys. Publish the metadata, then press a media key (or use the lock-screen
///     controls): the browser pushes the action to C#, which appends it to the log (the handler calls
///     <c>StateHasChanged()</c>, the sanctioned pattern for an externally-pushed update). Honored fully only
///     while media is actually playing.
/// </summary>
public sealed partial class MediaSessionDemo(IMediaSession media) : Component, IAsyncDisposable
{
    private readonly List<IAsyncDisposable> _handlers = [];
    private string _status = "(idle)";
    private string _last = "(none yet)";

    protected override async Task OnRenderedAsync(bool firstRender)
    {
        if (!firstRender || _handlers.Count > 0)
        {
            return;
        }

        if (!await media.IsSupportedAsync())
        {
            _status = "Media Session not supported";
            StateHasChanged();
            return;
        }

        foreach (var action in new[]
        {
            MediaSessionAction.Play, MediaSessionAction.Pause,
            MediaSessionAction.PreviousTrack, MediaSessionAction.NextTrack
        })
        {
            var captured = action;
            try
            {
                _handlers.Add(await media.SetActionHandlerAsync(captured, () =>
                {
                    _last = captured.ToString();
                    StateHasChanged();
                    return Task.CompletedTask;
                }));
            }
            catch
            {
                // Browser doesn't support this particular action — skip it.
            }
        }
    }

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-3")[
                    Button.Class(Tw.BtnPrimary).Id("ms-publish").OnClickAsync(Publish)["Publish metadata"],
                    Button
                        .Class(Tw.BtnOutlinePrimary)
                        .Id("ms-playing")
                        .OnClickAsync(() => SetState(PlaybackState.Playing, "playing"))["Mark playing"],
                    Button
                        .Class(Tw.BtnOutlinePrimary)
                        .Id("ms-paused")
                        .OnClickAsync(() => SetState(PlaybackState.Paused, "paused"))["Mark paused"],
                    Button.Class(Tw.BtnOutlineDanger).Id("ms-clear").OnClickAsync(Clear)["Clear"]
                ],
                P.Class("text-sm text-ui-muted mb-2")[
                    "After publishing, use your keyboard's media keys (or the OS media controls) — the action "
                    + "shows below. Lock-screen integration activates fully while audio is playing."],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("ms-status")[_status]],
                Div.Class("text-sm text-ui-muted")["Last action: ", Code.Id("ms-last")[_last]]
            ]
        ];

    private async Task Publish()
    {
        try
        {
            await media.SetMetadataAsync(new MediaMetadata
            {
                Title = "Rask Showcase Track",
                Artist = "Rask",
                Album = "Browser APIs",
                Artwork = [new MediaArtwork("icon.svg", "any", "image/svg+xml")]
            });
            _status = "metadata published";
        }
        catch (Exception ex)
        {
            _status = "publish failed: " + ex.Message;
        }
    }

    private async Task SetState(PlaybackState state, string label)
    {
        try
        {
            await media.SetPlaybackStateAsync(state);
            _status = $"playback state: {label}";
        }
        catch (Exception ex)
        {
            _status = "set state failed: " + ex.Message;
        }
    }

    private async Task Clear()
    {
        try
        {
            await media.ClearAsync();
            _status = "cleared";
            _last = "(none yet)";
        }
        catch (Exception ex)
        {
            _status = "clear failed: " + ex.Message;
        }
    }

    public async ValueTask DisposeAsync()
    {
        foreach (var handler in _handlers)
        {
            await handler.DisposeAsync();
        }
    }
}
Live result

After publishing, use your keyboard's media keys (or the OS media controls) — the action shows below. Lock-screen integration activates fully while audio is playing.

Status: (idle)
Last action: (none yet)

ICrypto — cryptographically strong randomness and SHA hashing (the Web Crypto API).

CryptoDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary><see cref="ICrypto" /> — native randomness (UUID, bytes) and hashing (SHA-256) from C#.</summary>
public sealed partial class CryptoDemo(ICrypto crypto) : Component
{
    private string _text = "hello";
    private string? _uuid;
    private string? _hash;
    private string? _bytes;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Button.Class(Tw.BtnOutlinePrimary).Id("crypto-uuid").OnClickAsync(Uuid)["Random UUID"],
                    Button.Class(Tw.BtnOutlinePrimary).Id("crypto-bytes").OnClickAsync(Bytes)[
                        "Random bytes"]
                ],
                Div.Class("text-sm text-ui-muted")["UUID: ", Code.Id("crypto-uuid-value")[_uuid ?? "(none)"]],
                Div.Class("text-sm text-ui-muted mb-2")["Bytes: ", Code.Id("crypto-bytes-value")[_bytes ?? "(none)"]],
                Input
                    .Value(_text)
                    .Id("crypto-text")
                    .Class($"{Tw.Input} mb-2")
                    .OnInput(v => _text = v),
                Button.Class($"{Tw.BtnPrimary} mb-2").Id("crypto-hash").OnClickAsync(Hash)["SHA-256"],
                Div.Class("text-sm text-ui-muted text-break")["Hash: ", Code.Id("crypto-hash-value")[_hash ?? "(none)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("crypto-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task Uuid()
    {
        try { _uuid = await crypto.RandomUuidAsync(); _status = "UUID generated"; }
        catch (Exception ex) { _status = "Failed: " + ex.Message; }
    }

    private async Task Bytes()
    {
        try
        {
            var b = await crypto.RandomBytesAsync(8);
            _bytes = Convert.ToHexStringLower(b);
            _status = "Bytes generated";
        }
        catch (Exception ex) { _status = "Failed: " + ex.Message; }
    }

    private async Task Hash()
    {
        try { _hash = await crypto.DigestHexAsync(HashAlgorithm.Sha256, _text); _status = "Hashed"; }
        catch (Exception ex) { _status = "Failed: " + ex.Message; }
    }
}
Live result
UUID: (none)
Bytes: (none)
Hash: (none)
Status: (idle)

IFileSystemAccess — open a file, edit it, and save it back to the same file (Chromium-family).

FileSystemAccessDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IFileSystemAccess" /> — a tiny text editor: open a file from disk, edit it, and save it
///     <em>back to the same file</em> (or "Save as…" to a new one). Falls back to a notice where the API is
///     unsupported (Firefox/Safari).
/// </summary>
public sealed partial class FileSystemAccessDemo(IFileSystemAccess files) : Component, IAsyncDisposable
{
    private IFileHandle? _handle;
    private string _text = string.Empty;
    private string _status = "(idle)";

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Button.Class(Tw.BtnPrimary).Id("fs-open").OnClickAsync(Open)[
                        UiIcon.Name(UiIconName.Folder).Class("me-1"), "Open file"],
                    Button
                        .Class(Tw.BtnOutlinePrimary)
                        .Id("fs-save")
                        .Disabled(_handle is null)
                        .OnClickAsync(Save)[UiIcon.Name(UiIconName.Save).Class("me-1"), "Save"],
                    Button.Class(Tw.BtnOutlinePrimary).Id("fs-saveas").OnClickAsync(SaveAs)[
                        "Save as…"]
                ],
                Div.Class("mb-2 text-sm text-ui-muted")["File: ", Code.Id("fs-name")[_handle?.Name ?? "(none)"]],
                Textarea
                    .Value(_text)
                    .Id("fs-text")
                    .Class($"{Tw.Input} mb-2")
                    .Rows(8)
                    .Placeholder("Open a text file, or type here and Save as…")
                    .OnInput(v => _text = v),
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("fs-status")[_status]]
            ]
        ];

    private async Task Open()
    {
        try
        {
            if (!await files.IsSupportedAsync())
            {
                _status = "File System Access not supported — use Chrome/Edge";
                return;
            }

            var handle = await files.OpenFileAsync(new FilePickerOptions
            {
                Description = "Text files",
                Accept = new Dictionary<string, string[]> { ["text/plain"] = [".txt", ".md", ".json", ".cs"] }
            });
            if (handle is null)
            {
                _status = "Open cancelled";
                return;
            }

            await ReplaceHandle(handle);
            _text = await handle.ReadTextAsync();
            _status = $"Opened {handle.Name} ({_text.Length} chars)";
        }
        catch (Exception ex)
        {
            _status = "Open failed: " + ex.Message;
        }
    }

    private async Task Save()
    {
        if (_handle is null)
        {
            return;
        }

        try
        {
            await _handle.WriteTextAsync(_text);
            _status = $"Saved {_handle.Name}";
        }
        catch (Exception ex)
        {
            _status = "Save failed: " + ex.Message;
        }
    }

    private async Task SaveAs()
    {
        try
        {
            if (!await files.IsSupportedAsync())
            {
                _status = "File System Access not supported — use Chrome/Edge";
                return;
            }

            var handle = await files.SaveFileAsync(new SaveFilePickerOptions { SuggestedName = "rask-note.txt" });
            if (handle is null)
            {
                _status = "Save cancelled";
                return;
            }

            await ReplaceHandle(handle);
            await handle.WriteTextAsync(_text);
            _status = $"Saved to {handle.Name}";
        }
        catch (Exception ex)
        {
            _status = "Save failed: " + ex.Message;
        }
    }

    // Drop the previous JS-side handle before adopting a new one, so handles don't leak across opens.
    private async Task ReplaceHandle(IFileHandle handle)
    {
        if (_handle is not null)
        {
            await _handle.DisposeAsync();
        }

        _handle = handle;
    }

    public async ValueTask DisposeAsync()
    {
        if (_handle is not null)
        {
            await _handle.DisposeAsync();
        }
    }
}
Live result
File: (none)
Status: (idle)

IOriginPrivateFileSystem — a private, persistent file tree the app owns: no picker, addressed by path, written in byte ranges. The right home for a local database file.

OriginPrivateFileSystemDemo.cs

using System.Text;
using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IOriginPrivateFileSystem" /> — write a byte range into an app-owned file, read it back,
///     and ask for the origin's storage to survive eviction.
/// </summary>
public sealed partial class OriginPrivateFileSystemDemo(
    IOriginPrivateFileSystem fs,
    IStorageEstimator storage) : Component
{
    private const string Path = "demo/notes.bin";
    private const long Offset = 4096;

    private string? _content;
    private string? _size;
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex flex-wrap gap-2 mb-2")[
                    Button.Type("button").Class(Tw.BtnOutlinePrimary)
                        .Id("opfs-write")
                        .OnClickAsync(Write)[
                        "Write at 4096"],
                    Button.Type("button").Class(Tw.BtnOutlineSecondary)
                        .Id("opfs-read")
                        .OnClickAsync(Read)[
                        "Read back"],
                    Button.Type("button").Class(Tw.BtnOutlineSecondary)
                        .Id("opfs-persist")
                        .OnClickAsync(Persist)[
                        "Request persistence"]
                ],
                Div.Class("text-sm text-ui-muted")["Content: ", Code.Id("opfs-content")[_content ?? "(not read)"]],
                Div.Class("text-sm text-ui-muted")["File size: ", Code.Id("opfs-size")[_size ?? "(unknown)"]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("opfs-status")[_status ?? "(idle)"]]
            ]
        ];

    // Writing at an offset leaves everything outside the range intact and zero-fills the gap up to it, so
    // the file ends up larger than the bytes written — that's the point of a ranged write.
    private async Task Write()
    {
        if (!await Supported())
        {
            return;
        }

        try
        {
            await fs.WriteAsync(Path, Offset, Encoding.UTF8.GetBytes("hello from OPFS"));
            _size = await fs.GetSizeAsync(Path) is { } size ? $"{size} bytes" : "(missing)";
            _status = "Wrote 15 bytes at offset 4096";
        }
        catch (Exception ex) { _status = "Write failed: " + ex.Message; }
    }

    private async Task Read()
    {
        if (!await Supported())
        {
            return;
        }

        try
        {
            var bytes = await fs.ReadAsync(Path, Offset, 15);
            _content = bytes is null ? "(file does not exist)" : Encoding.UTF8.GetString(bytes);
            _status = "Read 15 bytes at offset 4096";
        }
        catch (Exception ex) { _status = "Read failed: " + ex.Message; }
    }

    // OPFS is persistent but still evictable under storage pressure until the origin is exempted.
    private async Task Persist()
    {
        try
        {
            var persisted = await storage.IsPersistedAsync() || await storage.RequestPersistAsync();
            _status = persisted
                ? "Storage is exempt from eviction"
                : "Storage is still evictable (declined or unsupported)";
        }
        catch (Exception ex) { _status = "Persist request failed: " + ex.Message; }
    }

    private async Task<bool> Supported()
    {
        if (await fs.IsSupportedAsync())
        {
            return true;
        }

        _status = "OPFS unavailable in this browser";
        return false;
    }
}
Live result
Content: (not read)
File size: (unknown)
Status: (idle)

IWebAuthn — register and sign in with a passkey instead of a password.

WebAuthnDemo.cs

using System.Buffers.Text;
using System.Security.Cryptography;
using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IWebAuthn" /> — register a passkey, then sign in with it. The challenge is normally issued
///     and verified by your <em>backend</em>; this demo generates it client-side and just displays the
///     returned attestation/assertion (no server verification), to show the browser round-trip.
/// </summary>
public sealed partial class WebAuthnDemo(IWebAuthn webAuthn) : Component
{
    // A stable user handle for this demo session (a real app uses the account's server-side id).
    private readonly string _userId = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(16));
    private string? _credentialId;
    private string _status = "(idle)";
    private string _support = "(unchecked)";

    protected override async Task OnRenderedAsync(bool firstRender)
    {
        if (!firstRender)
        {
            return;
        }

        try
        {
            if (!await webAuthn.IsSupportedAsync())
            {
                _support = "WebAuthn not supported in this browser";
            }
            else
            {
                var platform = await webAuthn.IsPlatformAuthenticatorAvailableAsync();
                _support = platform
                    ? "Supported — platform authenticator available"
                    : "Supported — security key only";
            }
        }
        catch (Exception ex)
        {
            _support = "Support check failed: " + ex.Message;
        }

        StateHasChanged();
    }

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Button.Class(Tw.BtnPrimary).Id("webauthn-create").OnClickAsync(Create)[
                        UiIcon.Name(UiIconName.FingerPrint).Class("me-1"), "Create passkey"],
                    Button
                        .Class(Tw.BtnOutlinePrimary)
                        .Id("webauthn-auth")
                        .Disabled(_credentialId is null)
                        .OnClickAsync(Authenticate)["Authenticate"]
                ],
                Div.Class("text-sm text-ui-muted")["Support: ", Code.Id("webauthn-support")[_support]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("webauthn-status")[_status]]
            ]
        ];

    private async Task Create()
    {
        try
        {
            var result = await webAuthn.CreateAsync(new PublicKeyCredentialCreationOptions
            {
                Challenge = NewChallenge(),
                Rp = new RelyingParty("Rask Showcase"),
                User = new PublicKeyCredentialUser(_userId, "demo@rask.dev", "Rask Demo"),
                AuthenticatorSelection = new AuthenticatorSelection { UserVerification = "preferred" }
            });

            if (result is null)
            {
                _status = "Registration cancelled";
                return;
            }

            _credentialId = result.Id;
            _status = $"Passkey created (credential {Shorten(result.Id)}) — now Authenticate";
        }
        catch (Exception ex)
        {
            _status = "Registration failed: " + ex.Message;
        }
    }

    private async Task Authenticate()
    {
        try
        {
            var result = await webAuthn.GetAsync(new PublicKeyCredentialRequestOptions
            {
                Challenge = NewChallenge(),
                UserVerification = "preferred",
                AllowCredentials = _credentialId is null
                    ? null
                    : [new CredentialDescriptor(_credentialId)]
            });

            _status = result is null
                ? "Authentication cancelled"
                : $"Signed in — assertion received (signature {Shorten(result.Signature)}). A real app verifies it server-side.";
        }
        catch (Exception ex)
        {
            _status = "Authentication failed: " + ex.Message;
        }
    }

    private static string NewChallenge() => Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));

    private static string Shorten(string b64) => b64.Length <= 12 ? b64 : b64[..12] + "…";
}
Live result
Support: (unchecked)
Status: (idle)

IBroadcastChannel — send messages between same-origin tabs (open this guide in a second tab to try it).

BroadcastChannelDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IBroadcastChannel" /> — same-origin messaging between browsing contexts. This demo opens
///     two connections to one channel in the same page: posting on the sender is delivered to the receiver
///     (a connection never receives its own posts). Open this page in a second tab to see cross-tab
///     delivery. The receiver updates state in its handler and calls <c>StateHasChanged()</c> — the
///     sanctioned pattern for an externally-pushed update (same as subscribing to a background feed).
/// </summary>
public sealed partial class BroadcastChannelDemo(IBroadcastChannel bus) : Component, IAsyncDisposable
{
    private const string ChannelName = "rask-broadcast-demo";
    private IBroadcastChannelConnection? _sender;
    private IBroadcastChannelConnection? _receiver;
    private readonly List<string> _received = [];
    private int _counter;
    private bool _opened;

    protected override async Task OnRenderedAsync(bool firstRender)
    {
        if (!firstRender || _opened)
        {
            return;
        }

        _opened = true;
        _sender = await bus.OpenAsync(ChannelName, _ => Task.CompletedTask);
        _receiver = await bus.OpenAsync(ChannelName, msg =>
        {
            _received.Insert(0, msg);
            StateHasChanged();
            return Task.CompletedTask;
        });
    }

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Button.Class($"{Tw.BtnPrimary} mb-2").Type("button").Id("bc-send").OnClickAsync(Send)["Broadcast a message"],
                Div.Class("text-sm text-ui-muted mb-1")["Received (from other connections/tabs):"],
                _received.Count == 0
                    ? Div.Class("text-sm text-ui-muted italic").Id("bc-log")["(none yet)"]
                    : Ul.Class("text-sm mb-0").Id("bc-log")[
                        _received.Select(m => Li.Key(m)[m])
                    ]
            ]
        ];

    private async Task Send()
    {
        if (_sender is null)
        {
            return;
        }

        await _sender.PostAsync($"Message #{++_counter}");
    }

    public async ValueTask DisposeAsync()
    {
        if (_sender is not null)
        {
            await _sender.DisposeAsync();
        }

        if (_receiver is not null)
        {
            await _receiver.DisposeAsync();
        }
    }
}
Live result
Received (from other connections/tabs):
(none yet)

IWebLocks — serialise work across an origin's tabs/workers: RequestAsync(name, work) waits for the named lock, runs work while holding it, then releases (even if work throws); TryRequestAsync returns false without waiting when the lock is already held. Open this guide in a second tab and click "Hold" in both to watch one wait for the other.

WebLocksDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IWebLocks" /> — coordinate work across the tabs/workers of one origin. This demo holds an
///     exclusive lock for two seconds: open this page in a second tab and click "Hold" in both — the second
///     waits for the first to release. "Try (no wait)" uses <c>ifAvailable</c>, so it reports
///     <c>false</c> immediately while the lock is held. "Query" snapshots the locks the origin holds now.
/// </summary>
public sealed partial class WebLocksDemo(IWebLocks locks) : Component
{
    private const string LockName = "rask-web-locks-demo";
    private string _status = "(idle)";
    private bool _holding;
    private IReadOnlyList<LockInfo> _snapshot = [];

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Button.Type("button").Class(Tw.BtnPrimary).Id("locks-hold").OnClickAsync(Hold)[
                        "Hold exclusive for 2s"],
                    Button.Type("button").Class(Tw.BtnOutlinePrimary)
                        .Id("locks-try")
                        .OnClickAsync(TryHold)[
                        "Try (no wait)"],
                    Button.Type("button").Class(Tw.BtnOutlineSecondary)
                        .Id("locks-query")
                        .OnClickAsync(Query)[
                        "Query held locks"]
                ],
                Div.Class("text-sm text-ui-muted mb-1")["Status: ", Code.Id("locks-status")[_status]],
                _snapshot.Count == 0
                    ? Div.Class("text-sm text-ui-muted italic").Id("locks-snapshot")["(query to see held locks)"]
                    : Ul.Class("text-sm mb-0").Id("locks-snapshot")[
                        _snapshot.Select(l => Li.Key($"{l.Name}:{l.ClientId}:{l.Held}")[
                            $"{l.Name} — {l.Mode} — {(l.Held ? "held" : "pending")}"])
                    ]
            ]
        ];

    private async Task Hold()
    {
        if (!await locks.IsSupportedAsync())
        {
            _status = "not supported";
            return;
        }

        _holding = true;
        _status = "waiting for the lock…";
        StateHasChanged();
        try
        {
            await locks.RequestAsync(LockName, async () =>
            {
                _status = "holding — other tabs wait here";
                StateHasChanged();
                await Task.Delay(2000);
            });
            _status = "released";
        }
        catch (Exception ex)
        {
            _status = "failed: " + ex.Message;
        }
        finally
        {
            _holding = false;
        }
    }

    private async Task TryHold()
    {
        try
        {
            var got = await locks.TryRequestAsync(LockName, () => Task.CompletedTask);
            _status = got ? "try: acquired (and released)" : "try: already held — stood down";
        }
        catch (Exception ex)
        {
            _status = "failed: " + ex.Message;
        }
    }

    private async Task Query()
    {
        try
        {
            _snapshot = await locks.QueryAsync();
            _status = _holding ? "holding — see snapshot" : $"queried: {_snapshot.Count} lock(s)";
        }
        catch (Exception ex)
        {
            _status = "failed: " + ex.Message;
        }
    }
}
Live result
Status: (idle)
(query to see held locks)

IWebRtc — connect two browsers directly for peer-to-peer data. You supply the signaling (a WebSocket, an HTTP endpoint, even IBroadcastChannel between two tabs); the wrapper handles the offer/answer exchange, ICE, and data channels. Incoming messages and candidates arrive in batches — on the Server host each push costs a WebSocket frame, so one push per message would end the session under load. The demo runs both peers in one page, so signaling is a method call and everything else is real.

WebRtcDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IWebRtc" /> — a peer-to-peer data channel between two browsers. This demo puts
///     <em>both</em> peers in one page, so the signaling step is a plain method call rather than a network
///     hop; in a real app that is exactly where your WebSocket, HTTP endpoint or
///     <see cref="IBroadcastChannel" /> goes. Everything else is the real thing: a real offer/answer
///     exchange, real ICE candidates, and a real <c>RTCDataChannel</c> carrying the messages.
///     <para>
///         Two details are worth copying. Candidates are <b>buffered until the remote description is
///         applied</b> — a candidate that arrives first is rejected by the browser, and this is the single
///         most common way a first WebRTC integration fails. And messages arrive as a <b>batch</b>: the
///         framework coalesces them, because on the Server host one push per message would be one
///         WebSocket frame per message.
///     </para>
/// </summary>
public sealed partial class WebRtcDemo(IWebRtc rtc) : Component, IAsyncDisposable
{
    private readonly List<string> _log = [];
    private readonly List<RtcIceCandidate> _pendingForCaller = [];
    private readonly List<RtcIceCandidate> _pendingForCallee = [];

    private IPeerConnection? _caller;
    private IPeerConnection? _callee;
    private IRtcDataChannel? _chat;

    private bool _callerReady;
    private bool _calleeReady;
    private bool _connecting;
    private bool _everConnected;
    private int _localCandidates;
    private int _sent;
    private string _state = "not connected";
    private bool _supported = true;

    protected override async Task OnRenderedAsync(bool firstRender)
    {
        if (!firstRender)
        {
            return;
        }

        _supported = await rtc.IsSupportedAsync();
        if (!_supported)
        {
            StateHasChanged();
        }
    }

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                !_supported
                    ? Div.Class("text-sm text-ui-muted italic").Id("rtc-state")[
                        "This browser has no WebRTC support."]
                    : Div[
                        Div.Class("flex gap-2 mb-2")[
                            Button.Type("button").Class(Tw.BtnPrimary)
                                .Id("rtc-connect")
                                .Disabled(_connecting)
                                .OnClickAsync(ConnectAsync)["Connect the two peers"],
                            Button.Type("button").Class(Tw.BtnSecondary)
                                .Id("rtc-send")
                                .Disabled(!_everConnected)
                                .OnClickAsync(SendAsync)["Send a message"]
                        ],
                        Div.Class("text-sm text-ui-muted mb-1")[
                            "Connection state: ", Span.Id("rtc-state")[_state]],
                        Div.Class("text-sm text-ui-muted mb-1")[
                            "Local ICE candidates gathered: ",
                            Span.Id("rtc-candidates")[_localCandidates.ToString()]],
                        Div.Class("text-sm text-ui-muted mb-1")["Received by the other peer:"],
                        _log.Count == 0
                            ? Div.Class("text-sm text-ui-muted italic").Id("rtc-log")["(nothing yet)"]
                            : Ul.Class("text-sm mb-0").Id("rtc-log")[
                                _log.Select(m => Li.Key(m)[m])
                            ]
                    ]
            ]
        ];

    private async Task ConnectAsync()
    {
        if (_connecting)
        {
            return;
        }

        _connecting = true;
        _state = "connecting";
        StateHasChanged();

        // The caller. Its local candidates belong to the callee — in a real app, this is a signaling send.
        _caller = await rtc.CreateAsync(new RtcConfiguration(), new RtcHandlers
        {
            OnIceCandidates = candidates => DeliverAsync(candidates, toCaller: false),
            OnConnectionStateChanged = state =>
            {
                _state = state.ToString().ToLowerInvariant();
                _everConnected |= state == RtcConnectionState.Connected;
                StateHasChanged();
                return Task.CompletedTask;
            }
        });

        // The callee. It learns about the channel through OnDataChannel, the way a remote peer always does.
        _callee = await rtc.CreateAsync(new RtcConfiguration(), new RtcHandlers
        {
            OnIceCandidates = candidates => DeliverAsync(candidates, toCaller: true),
            OnDataChannel = channel => channel.ListenAsync(ReceiveAsync).AsTask()
        });

        _chat = await _caller.CreateDataChannelAsync("chat");
        await _chat.ListenAsync(ReceiveAsync);

        var offer = await _caller.CreateOfferAsync();
        await _caller.SetLocalDescriptionAsync(offer);
        await _callee.SetRemoteDescriptionAsync(offer);
        _calleeReady = true;

        var answer = await _callee.CreateAnswerAsync();
        await _callee.SetLocalDescriptionAsync(answer);
        await _caller.SetRemoteDescriptionAsync(answer);
        _callerReady = true;

        await FlushAsync();
        StateHasChanged();
    }

    // Hands a batch of candidates to the other peer, holding them back until that peer has a remote
    // description. addIceCandidate throws before then, and gathering can easily outrun the answer.
    private async Task DeliverAsync(IReadOnlyList<RtcIceCandidate> candidates, bool toCaller)
    {
        var target = toCaller ? _caller : _callee;
        var ready = toCaller ? _callerReady : _calleeReady;
        var pending = toCaller ? _pendingForCaller : _pendingForCallee;

        // Counted for the demo's own display: this is the batch the browser pushed into C#, so a non-zero
        // count is proof the whole gather → coalesce → [JSInvokable] → callback path ran.
        _localCandidates += candidates.Count;
        StateHasChanged();

        if (target is null || !ready)
        {
            pending.AddRange(candidates);
            return;
        }

        foreach (var candidate in candidates)
        {
            await target.AddIceCandidateAsync(candidate);
        }
    }

    private async Task FlushAsync()
    {
        await DrainAsync(_pendingForCaller, _caller, _callerReady);
        await DrainAsync(_pendingForCallee, _callee, _calleeReady);
        return;

        static async Task DrainAsync(List<RtcIceCandidate> pending, IPeerConnection? target, bool ready)
        {
            if (target is null || !ready)
            {
                return;
            }

            var buffered = pending.ToArray();
            pending.Clear();
            foreach (var candidate in buffered)
            {
                await target.AddIceCandidateAsync(candidate);
            }
        }
    }

    private async Task SendAsync()
    {
        if (_chat is null)
        {
            return;
        }

        await _chat.SendAsync($"Message #{++_sent}");
    }

    // The browser pushes here, so state changes need StateHasChanged() — a subscription, not a binding.
    private Task ReceiveAsync(IReadOnlyList<RtcMessage> messages)
    {
        foreach (var message in messages)
        {
            _log.Insert(0, message.Text ?? $"{message.Data?.Length ?? 0} bytes");
        }

        StateHasChanged();
        return Task.CompletedTask;
    }

    public async ValueTask DisposeAsync()
    {
        if (_caller is not null)
        {
            await _caller.DisposeAsync();
        }

        if (_callee is not null)
        {
            await _callee.DisposeAsync();
        }
    }
}
Live result
Connection state: not connected
Local ICE candidates gathered: 0
Received by the other peer:
(nothing yet)

ISignaling — the relay two peers trade an offer, an answer and their ICE candidates over, for apps that don't already have a channel of their own. Host it with AddRaskSignaling() + MapRaskSignaling(). Peer ids are minted by the server, a message only reaches a peer in the sender's own room, and nothing is ever echoed back to its sender. The demo joins the same room twice from one page, so you can watch the whole exchange.

SignalingDemo.cs

using Microsoft.JSInterop;
using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="ISignaling" /> — the relay two peers trade an offer, an answer and their ICE candidates
///     over before <see cref="IWebRtc" /> can connect them. This demo opens <em>two</em> connections to the
///     same room from one page, so you can watch the whole exchange: the second one is told who was already
///     there, the first is told someone arrived, and a payload sent to one id comes out at that peer and
///     nowhere else.
///     <para>
///         The payload is an opaque string — here it's plain text; in a real app it's a serialized
///         <c>RtcDescription</c> or <c>RtcIceCandidate</c>. Neither the relay nor the wrapper looks inside.
///     </para>
/// </summary>
public sealed partial class SignalingDemo(ISignaling signaling) : Component, IAsyncDisposable
{
    private const string Room = "rask-signaling-demo";

    private readonly List<string> _log = [];
    private ISignalingConnection? _first;
    private ISignalingConnection? _second;
    private string? _firstId;
    private string? _secondId;
    private bool _joining;
    private bool _unavailable;
    private int _sent;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 mb-2")[
                    Button.Type("button").Class(Tw.BtnPrimary)
                        .Id("signal-join")
                        .Disabled(_joining)
                        .OnClickAsync(JoinAsync)["Join the room twice"],
                    Button.Type("button").Class(Tw.BtnSecondary)
                        .Id("signal-send")
                        .Disabled(_secondId is null)
                        .OnClickAsync(SendAsync)["Relay a payload"]
                ],
                _unavailable
                    ? Div.Class("text-sm text-ui-muted italic").Id("signal-status")[
                        "This host isn't running the relay — it needs AddRaskSignaling() + "
                        + "MapRaskSignaling() on the server."]
                    : Div.Class("text-sm text-ui-muted mb-1")[
                    "Peers: ",
                    Span.Id("signal-peers")[_firstId is null ? "none" : $"{Short(_firstId)} + {Short(_secondId)}"]],
                Div.Class("text-sm text-ui-muted mb-1")["What the relay reported:"],
                _log.Count == 0
                    ? Div.Class("text-sm text-ui-muted italic").Id("signal-log")["(nothing yet)"]
                    : Ul.Class("text-sm mb-0").Id("signal-log")[
                        _log.Select(m => Li.Key(m)[m])
                    ]
            ]
        ];

    private static string Short(string? id) => id is null ? "?" : id[..Math.Min(6, id.Length)];

    private async Task JoinAsync()
    {
        if (_joining)
        {
            return;
        }

        _joining = true;
        StateHasChanged();

        // A host that doesn't map the relay refuses the socket. Say so plainly rather than failing
        // silently — the showcase's WASM host serves static files and has no relay to offer.
        try
        {
            await ConnectAsync();
        }
        catch (Exception ex) when (ex is JSException or InvalidOperationException)
        {
            _unavailable = true;
            StateHasChanged();
        }
    }

    private async Task ConnectAsync()
    {
        _first = await signaling.JoinAsync(Room, new SignalingHandlers
        {
            OnJoined = (self, peers) =>
            {
                _firstId = self;
                Log($"first joined as {Short(self)}, saw {peers.Count} peer(s)");
                return Task.CompletedTask;
            },
            // The relay tells everyone already in the room that someone arrived.
            OnPeerJoined = id =>
            {
                Log($"first was told {Short(id)} arrived");
                return Task.CompletedTask;
            },
            OnSignal = (from, payload) =>
            {
                Log($"first received \"{payload}\" from {Short(from)}");
                return Task.CompletedTask;
            },
            OnError = message =>
            {
                Log($"relay refused: {message}");
                return Task.CompletedTask;
            }
        });

        _second = await signaling.JoinAsync(Room, new SignalingHandlers
        {
            // The peers already present are the ones this connection would offer to — the rule that stops
            // both sides offering at once.
            OnJoined = (self, peers) =>
            {
                _secondId = self;
                Log($"second joined as {Short(self)}, saw {peers.Count} peer(s)");
                return Task.CompletedTask;
            },
            OnError = message =>
            {
                Log($"relay refused: {message}");
                return Task.CompletedTask;
            }
        });
    }

    private async Task SendAsync()
    {
        if (_second is null || _firstId is null)
        {
            return;
        }

        await _second.SendAsync(_firstId, $"payload #{++_sent}");
    }

    // The relay pushes into these, so state changes need StateHasChanged() — a subscription, not a binding.
    private void Log(string line)
    {
        _log.Insert(0, line);
        StateHasChanged();
    }

    public async ValueTask DisposeAsync()
    {
        if (_first is not null)
        {
            await _first.DisposeAsync();
        }

        if (_second is not null)
        {
            await _second.DisposeAsync();
        }
    }
}
Live result
Peers: none
What the relay reported:
(nothing yet)

INotifications + IBadge — raise a local notification and set the app-icon badge from the page. They use the browser's Notifications and Badging APIs (a badge only shows on an installed PWA). On iOS the badge is numeric-only.

NotificationsDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="INotifications" /> + <see cref="IBadge" /> — raise a local notification and set the app-icon
///     badge from the page. Both work on every host, through the browser's Notifications and Badging APIs
///     (a badge only shows on an installed PWA).
/// </summary>
public sealed partial class NotificationsDemo(INotifications notifications, IBadge badge) : Component
{
    private string? _status;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Button.Type("button").Class(Tw.BtnOutlinePrimary)
                        .Id("notif-permission")
                        .OnClickAsync(RequestPermission)["Request permission"],
                    Button.Type("button").Class(Tw.BtnOutlinePrimary)
                        .Id("notif-show")
                        .OnClickAsync(Notify)["Notify"],
                    Button.Type("button").Class(Tw.BtnOutlineSecondary)
                        .Id("badge-set")
                        .OnClickAsync(SetBadge)["Set badge 3"],
                    Button.Type("button").Class(Tw.BtnOutlineDanger)
                        .Id("badge-clear")
                        .OnClickAsync(ClearBadge)["Clear badge"]
                ],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("notif-status")[_status ?? "(idle)"]]
            ]
        ];

    private async Task RequestPermission()
    {
        if (!await notifications.IsSupportedAsync())
        {
            _status = "Notifications not supported on this host";
            return;
        }

        _status = $"Permission: {await notifications.RequestPermissionAsync()}";
    }

    private async Task Notify()
    {
        if (!await notifications.IsSupportedAsync())
        {
            _status = "Notifications not supported on this host";
            return;
        }

        // Showing without permission throws (matching the browser), so gate on it and prompt the user first.
        if (await notifications.PermissionAsync() != NotificationPermission.Granted)
        {
            _status = "Grant permission first";
            return;
        }

        await notifications.ShowAsync("Rask", new NotificationOptions { Body = "Hello from your Rask app.", Tag = "demo" });
        _status = "Notification sent";
    }

    private async Task SetBadge()
    {
        if (!await badge.IsSupportedAsync())
        {
            _status = "Badge not supported on this host";
            return;
        }

        await badge.SetAsync(3);
        _status = "Badge set to 3";
    }

    private async Task ClearBadge()
    {
        await badge.ClearAsync();
        _status = "Badge cleared";
    }
}
Live result
Status: (idle)

Shareable (Rask.Core — all hosts) — headless share: hand your element the data-rask-share attribute and its click opens the OS share sheet, on every host including Server (the shared client fires navigator.share in the click gesture, so the activation survives). For a code-driven share on the WASM host, inject IShare (Rask.Wasm.Browser) instead.

Shareable (Rask.Core) is headless — you render the trigger element, it hands you the data-rask-share attribute to spread onto it. The shared client fires navigator.share inside the click gesture, so the transient user activation survives even on the Server transport (an imperative round-trip would lose it), and it works on every host. For a code-driven share on the WASM host, inject IShare from Rask.Wasm.Browser.

ShareDemo.cs

using Rask.Core.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <c>Shareable</c> (Rask.Core) — headless share: it hands <b>your</b> element the
///     <c>data-rask-share</c> attribute, so the click opens the OS share sheet from <b>any</b> host, the
///     Server included. The shared client fires <c>navigator.share</c> inside the gesture (no round-trip, so
///     the activation isn't lost). For a code-driven share on the in-process host, inject <c>IShare</c> from
///     <c>Rask.Wasm.Browser</c>.
/// </summary>
public sealed partial class ShareDemo : Component
{
    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    // Headless: we render our own button; Shareable just supplies the share attribute.
                    Shareable
                        .Data(new ShareData
                        {
                            Title = "Rask",
                            Text = "Build web apps in C# — one component model, server or WebAssembly.",
                            Url = "https://github.com/pal-tamas/rask"
                        })
                        .Template(share => Button
                            .Type("button")
                            .Class(Tw.BtnPrimary)
                            .Id("share-btn")
                            .Data(share)["Share this page"])
                ],
                Div.Class("text-sm text-ui-muted")[
                    "Works on every host — the click fires ", Code["navigator.share"],
                    " inside the gesture (so it works on Server too, where an imperative round-trip would lose "
                    + "the activation). Unsupported browsers (e.g. desktop Firefox) no-op."]
            ]
        ];
}
Live result
Works on every host — the click fires navigator.share inside the gesture (so it works on Server too, where an imperative round-trip would lose the activation). Unsupported browsers (e.g. desktop Firefox) no-op.

GestureTrigger + six typed triggers (Rask.Core — all hosts) — headless gesture bridge: hand your element the data-rask-gesture attribute and its click runs an activation-gated API in the gesture, so it works on Server too, where the imperative service can't be injected. Ships FullscreenTrigger, ScreenOrientationTrigger, EyeDropperTrigger, InstallTrigger, MediaCaptureTrigger, and PictureInPictureTrigger. See Gesture bridge.

The GestureTrigger family (Rask.Core) is headless like Shareable: each trigger hands your element a data-rask-gesture attribute and the shared client runs the activation-gated API inside the click gesture. That makes normally-WASM-only APIs reachable on every host, the Server included — where the imperative IFullscreen / IEyeDropper / … services can't be injected, because a round-trip would lose the transient user activation. Six typed triggers ship: FullscreenTrigger, ScreenOrientationTrigger, EyeDropperTrigger, InstallTrigger, MediaCaptureTrigger, and PictureInPictureTrigger (the last two target a <video> via its ElementRef). Capabilities that return a value (the eyedropper's hex, the install outcome) post it back to the OnColor / OnResult / OnOutcome callback.

GestureBridgeDemo.cs

using Rask.Core;
using Rask.Core.Browser;
using Rask.Core.Components;
using Rask.Html.Components;

namespace Rask.Site.Features;

/// <summary>
///     The full <c>GestureTrigger</c> family (Rask.Core) — the headless gesture bridge. Each trigger hands
///     <b>your</b> element a <c>data-rask-gesture</c> attribute; the shared client runs the activation-gated
///     browser API <b>inside the click gesture</b>, so these work on <b>every</b> host — the Server included,
///     where the imperative <c>IFullscreen</c> / <c>IEyeDropper</c> / … services can't be injected (a round-trip
///     would lose the transient user activation). <c>FullscreenTrigger</c> and <c>EyeDropperTrigger</c> are
///     joined here by <c>ScreenOrientationTrigger</c>, <c>InstallTrigger</c>, <c>MediaCaptureTrigger</c>, and
///     <c>PictureInPictureTrigger</c> (the last two target a <c>&lt;video&gt;</c> via its <c>ElementRef</c>).
/// </summary>
public sealed partial class GestureBridgeDemo(IMediaStreams streams) : Component
{
    private readonly ElementRef _preview = ElementRef.New();
    private string? _color;
    private string? _install;
    private MediaStreamId? _camera;

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 items-center flex-wrap mb-3")[
                    // Headless: we render our own buttons; the triggers just supply the gesture attribute.
                    FullscreenTrigger
                        .Template(g =>
                        Button.Type("button").Class(Tw.BtnPrimary).Id("fullscreen-btn").Data(g)[
                            "Enter fullscreen"]),
                    ScreenOrientationTrigger
                        .Orientation("landscape")
                        .Template(g =>
                            Button
                                .Type("button")
                                .Class(Tw.BtnOutlinePrimary)
                                .Id("orientation-btn")
                                .Data(g)["Lock landscape"]),
                    InstallTrigger
                        .Template(g =>
                            Button
                                .Type("button")
                                .Class(Tw.BtnOutlineSuccess)
                                .Id("install-btn")
                                .Data(g)["Install app"])
                        .OnOutcome(outcome =>
                        {
                            // No StateHasChanged: the trigger is a Component rather than an Element, so its
                            // callback is auto-wrapped and this demo repaints when the handler returns.
                            _install = outcome;
                            return Task.CompletedTask;
                        }),
                    _install is null
                        ? Span.Class("text-sm text-ui-muted")["not prompted"]
                        : Span.Class("text-sm")["install: ", Code.Id("install-outcome")[_install]]
                ],
                Div.Class("flex gap-2 items-center flex-wrap mb-2")[
                    EyeDropperTrigger
                        .Template(g =>
                            Button
                                .Type("button")
                                .Class(Tw.BtnOutlineSecondary)
                                .Id("eyedropper-btn")
                                .Data(g)["Pick a colour"])
                        .OnColor(hex =>
                        {
                            _color = hex;
                            return Task.CompletedTask;
                        }),
                    _color is null
                        ? Span.Class("text-sm text-ui-muted")["no colour picked"]
                        : Span.Class("inline-flex items-center gap-2 text-sm")[
                            Span
                                .Id("eyedropper-swatch")
                                .Style("display:inline-block;width:1.25rem;height:1.25rem;border-radius:.25rem;"
                                       + $"border:1px solid #ccc;background:{_color}"),
                            Code.Id("eyedropper-value")[_color]]
                ],
                // MediaCaptureTrigger fills this <video> from the camera; PictureInPictureTrigger then pops
                // that same element out — both resolve the element from its ElementRef.
                Div.Class("flex gap-2 items-center flex-wrap items-center")[
                    // For and Template are the required steps, so they come first: until both are named
                    // the receiver is still a pending-required wrapper and has no optional setters on it.
                    MediaCaptureTrigger
                        .For(_preview)
                        .Template(g =>
                            Button
                                .Type("button")
                                .Class(Tw.BtnOutlineSecondary)
                                .Id("camera-btn")
                                .Data(g)["Start camera"])
                        .Video(true)
                        .FacingMode("user")
                        // OnStream keeps the started stream reachable from C# — the only way a Server-hosted
                        // app can hold one, and what makes the stop button below possible at all. No
                        // StateHasChanged: the trigger is a Component, so its callback is auto-wrapped and
                        // this demo repaints when the handler returns (RASK026).
                        .OnStream(id =>
                        {
                            _camera = id;
                            return Task.CompletedTask;
                        }),
                    Button
                        .Type("button")
                        .Class(Tw.BtnOutlineSecondary)
                        .Id("camera-stop-btn")
                        .Disabled(_camera is null)
                        .OnClickAsync(StopCameraAsync)["Stop camera"],
                    PictureInPictureTrigger
                        .For(_preview)
                        .Template(g =>
                            Button
                                .Type("button")
                                .Class(Tw.BtnOutlineSecondary)
                                .Id("pip-btn")
                                .Data(g)["Pop out video"]),
                    Video
                        .Ref(_preview)
                        .Id("gesture-preview")
                        .Muted(true)
                        .Style("width:12rem;max-width:100%;border-radius:.25rem;background:#000")
                ],
                Div.Class("text-sm text-ui-muted mt-3")[
                    "Every button runs inside its own click gesture, so they all work on the Server too. ",
                    "Camera + picture-in-picture need HTTPS and a real device; install needs an installable PWA ",
                    "(", Code["AddRaskPwa"], "); orientation lock only takes effect while fullscreen (pair it ",
                    "with the fullscreen button on a phone); the eyedropper needs a Chromium browser. ",
                    "Stopping the camera goes through ", Code["IMediaStreams"], " on the id the capture ",
                    "trigger handed back — releasing the device and its hardware indicator."]
            ]
        ];

    // Stopping is not optional: a live stream holds the camera (and its indicator) open until every track
    // is stopped, and nothing else in the page will do it.
    private async Task StopCameraAsync()
    {
        if (_camera is not { } id)
        {
            return;
        }

        await streams.StopAsync(id);
        _camera = null;
    }
}
Live result
not prompted
no colour picked
Every button runs inside its own click gesture, so they all work on the Server too. Camera + picture-in-picture need HTTPS and a real device; install needs an installable PWA (AddRaskPwa); orientation lock only takes effect while fullscreen (pair it with the fullscreen button on a phone); the eyedropper needs a Chromium browser. Stopping the camera goes through IMediaStreams on the id the capture trigger handed back — releasing the device and its hardware indicator.