# JS interop — IJSRuntime, typed APIs & refs

Calling JS from C#, the typed browser-API layer, element refs, and wrapping a third-party JS library.
The browser-side half is TypeScript — a `.js` sibling is [RASK055](https://rask.sh/docs/guides/diagnostics/#rask055).

‹ Back to [JavaScript interop](https://rask.sh/docs/guides/js-interop/)

## Calling JS from C# (`IJSRuntime`)

**Your component's own scoped `.ts` needs none of this** — its exports are typed private methods
(`await Width(_box)`), see [Calling your script from C#](https://rask.sh/docs/guides/js-interop/#calling-your-script-from-c).
`IJSRuntime` is for everything else: a browser API, a library's global, another component's script.

Inject `IJSRuntime` through the **constructor** (not a property — a non-nullable settable
property would become a required chain step) and dispatch from a lifecycle hook or
event handler:

```csharp
public sealed partial class CodeSample : Component
{
    private readonly IJSRuntime _js;
    public CodeSample(IJSRuntime js) => _js = js;

    protected override async Task OnRendered() =>
        await _js.InvokeVoidAsync("hljs.highlightAll");
}
```

Nothing (no `el`) is passed automatically — pass what the function needs. For a return
value use `InvokeAsync<T>`. On WASM a non-primitive `T` must be rooted for the trimmer
(DAM annotation or a `JsonSerializerContext`).

A `sessionStorage` round-trip through the unified `IJSRuntime` — set, read, and remove, each a plain
`InvokeVoidAsync` / `InvokeAsync<string?>` against a built-in browser API, identical on both transports:


---

## Typed browser APIs

> For the **full map** of every wrapper (shared vs WASM-only, one-shot vs subscription), see the
> [Browser APIs overview](https://rask.sh/docs/guides/browser-apis/). This section covers the shared set and the transport "why".

Rather than spelling out raw `IJSRuntime` identifiers (`"localStorage.getItem"`,
`"navigator.clipboard.writeText"`) and getting the JSON shape right by hand, call MDN's own surface from
[`Rask.Web`](https://rask.sh/docs/guides/web-apis/) — `await LocalStorage.GetItem("theme")`, `await Navigator.Clipboard.WriteText("hi")`,
`await Crypto.RandomUUID()` —
or inject one of the built-in **typed wrappers** below through a component constructor. Each is a thin, awaitable layer over
the same unified `IJSRuntime`, so it behaves **identically on Server and WASM**. These are the
Web APIs that work on both transports; WASM-only PWA APIs (service worker, cache, manifest) are a
later step on the same pattern.

| Service | Wraps | Key members |
| --- | --- | --- |
| `IIndexedDb` | `IndexedDB` | `IsSupported`, `OpenStore(name)` → `IKeyValueStore` (`Set`/`Get`/`SetBytes`/`GetBytes`/`Delete`/`Keys`/`Clear`) — large async persistent storage, text or raw bytes |

The storage estimate is `await Navigator.Storage.Estimate()` in [`Rask.Web`](https://rask.sh/docs/guides/web-apis/), with `Persist()` and
`Persisted()` beside it. Device tilt and motion are `Window` events there:
`await Window.OnDeviceOrientation(e => _angle = e.Alpha, every: 100.Milliseconds)`, throttled in the browser by `every:`.

```csharp
public sealed partial class Drafts(IIndexedDb db) : Component
{
    private async Task Save() => await (await db.OpenStore("drafts")).Set("note", "hi");

    protected override async Task OnFirstRender()
    {
        var note = await (await db.OpenStore("drafts")).Get("note");   // string?, null if absent
        var cookies = await Document.Cookie;                                       // "a=1; b=2", from Rask.Web
        var id = await Crypto.RandomUUID();                                        // string, from Rask.Web
    }
}
```

Call them from an **event handler or lifecycle hook** (not from `Render()`). Some APIs (WebAuthn, the
file system pickers, `crypto.subtle`) are **browser-gated** — they need a secure context (HTTPS or localhost) and the user's
permission; a denial or timeout surfaces as a `JSException` from the awaited task, so wrap those
calls in `try/catch`.

**User-activation and the transport — why one API is WASM-only.** Some browser APIs require
*transient* activation: they must run inside the live user-gesture task. On **WASM** an event
handler's interop call runs synchronously in that gesture's call stack, so it qualifies; on **Server**
the click is forwarded over the WebSocket and the interop call runs a round-trip later, after the
transient activation has expired. The practical effect:

- **Sharing** splits by *when* you fire it. The headless declarative **`Shareable`** (`Rask.Core`) attaches
  `data-rask-share` to your element and the shared client fires `navigator.share` **inside the click's own
  call stack**, so the activation is still live — it therefore works on **every** host, Server included. The
  imperative **`await Navigator.Share(…)`** ([`Rask.Web`](https://rask.sh/docs/guides/web-apis/#what-only-webassembly-runs)) lets you
  share from *code* (a lifecycle hook, after an `await`), which needs the in-process transport to keep the
  activation — so it compiles only in a **WASM** app (on Server `navigator.share` would reject with
  "Must be handling a user gesture").
- **Fullscreen** and **Picture-in-Picture** need transient activation too. They are `await _stage.RequestFullscreen()`
  and `await _video.RequestPictureInPicture()` on an element ref in [`Rask.Web`](https://rask.sh/docs/guides/web-apis/#on-an-element-ref),
  generated into WASM only. On Server, `Trigger.Fullscreen` and `Trigger.PictureInPicture` run them in the click.
- A custom install button's **`await _prompt.Prompt()`** (the kept `beforeinstallprompt` from
  `Window.OnBeforeInstallPrompt`, [`Rask.Web`](https://rask.sh/docs/guides/web-apis/#where-a-browser-falls-short)) needs the click too, so it is
  **WASM-only**; on Server, `Trigger.Install` shows the same kept prompt in the click. Screen
  capture, `await Navigator.MediaDevices.GetDisplayMedia()`, is a [`Rask.Web`](https://rask.sh/docs/guides/web-apis/#what-only-webassembly-runs)
  call generated into WASM only. See the [Mobile & PWA guide](https://rask.sh/docs/guides/pwa/#device-capabilities-for-mobile).
- The **screen wake lock**, `await Navigator.WakeLock.Request(WakeLockType.Screen)` in
  [`Rask.Web`](https://rask.sh/docs/guides/web-apis/#where-a-browser-falls-short), needs no transient activation, so it works on both hosts. The app badge is `await Navigator.SetAppBadge(3)` in [`Rask.Web`](https://rask.sh/docs/guides/web-apis/).
- Everything else here (cookies, indexeddb) is unaffected by activation and
  behaves identically on both transports.

These are one-shot request/response calls. A *subscription* — `ISignaling`, `IWebRtc`, or a `Rask.Web` event such as
`Window.OnDeviceMotion` — hands back an `IAsyncDisposable` and the browser **pushes** each change to a C# handler.
Open it from a lifecycle hook and dispose of it on unmount. See
[the push pattern](https://rask.sh/docs/guides/browser-apis-sharing/#subscriptions--the-push-pattern).

This is the rule for the whole surface: **shared APIs live in `Rask.Core.Browser`; APIs that can't
work on Server live in `Rask.Wasm.Browser`** (the home for upcoming PWA-only APIs too).

Under the hood: the wrappers' helpers (and `__raskEl`) live in `src/Rask.Core/Resources/rask-api.ts` and are
imported by both client runtimes at build time, so the two transports never drift. The types they deserialize are
rooted for the WASM trimmer by the framework, so they work in a `PublishTrimmed` app.

Runnable demos: the **Browser APIs** section of the showcase at
[rask.sh/docs](https://rask.sh/docs) — one demo per API, from
[`src/Rask.Site/Features/Browser/`](https://github.com/pal-tamas/rask/blob/main/src/Rask.Site/Features/Browser/).

---

## Element refs

A ref is a handle on one rendered element. **Type it to the element's MDN interface and it carries that
interface's DOM members, generated from MDN** — the same data the elements and their events come from:

```csharp
public sealed partial class RefDemo : Component
{
    private readonly ElementRef<HTMLDialogElement> _dialog = new();   // a field: the id is stable across renders
    private readonly ElementRef<HTMLInputElement> _name = new();
    private readonly ElementRef<HTMLVideoElement> _video = new();

    protected override Component? Render() =>
        Div[
            Input.Of<string>().Ref(_name),
            Video.Src("/intro.mp4").Ref(_video),
            Button.OnClick(Open)["Open"],
            Dialog.Ref(_dialog)[Button.OnClick(async () => await _dialog.Close())["Close"]]
        ];

    private async Task Open()
    {
        await _dialog.ShowModal();                     // MDN's showModal()
        var open = await _dialog.Open;                 // MDN's open, read from the live element
        await _name.Focus();                           // HTMLElement's focus(), which a dialog ref has too
        var box = await _name.GetBoundingClientRect(); // a DOMRect record
        await _video.SetCurrentTime(12);               // a write, where the render does not own the value
        await _video.Play();
    }
}
```

- **An operation is a method, an attribute an awaitable read**, named as MDN names them, with no `Async`
  suffix and no `IJSRuntime` to inject: the ref finds its element's session itself. Each member's doc comment
  gives its browser support and links to MDN.
- **Bases come with it.** `IElementRef<out T>` is covariant, so an `ElementRef<HTMLDialogElement>` has
  `HTMLElement`'s `Focus()` and `Element`'s `ScrollIntoView(…)`, `GetBoundingClientRect()` and `ScrollTop`.
  An untyped `ElementRef.New()` carries `Element`'s.
- **Options are MDN's dictionaries, as records; IDL enums are enums:**
  `ScrollIntoView(new ScrollIntoViewOptions { Behavior = ScrollBehavior.Smooth, Block = ScrollLogicalPosition.Nearest })`.
- **The render stays in charge.** There is no setter for what the render writes — `Open`, `Id`, a control's
  `Value` — and nothing that rewrites the tree, the attributes or the content (`innerHTML`, `append`,
  `setAttribute`). Drive those from state; keep the ref for what only the live node knows or does.
- **Only members whose values cross the wire are there.** A member that takes a kept `Rask.Web` object takes its
  handle (`await _video.SetSrcObject(stream)`); one that hands back a live `Node` is left out, and a ref still hands
  the element to your own TypeScript for those.
- **A typed ref on the wrong element throws** where it is put: an `ElementRef<HTMLVideoElement>` on a `Div`.
- **A member a browser only allows during a click** (`RequestFullscreen()`, `RequestPointerLock()`, an input's
  `ShowPicker()`) is generated into `Rask.Wasm` alone, where the handler
  runs in the click. On the Server host the call would reach the browser after the click has ended and be
  refused, so there it does not compile; `Trigger.Fullscreen` and `Trigger.PictureInPicture` run it in the click
  from markup instead.

A ref still serializes as `{"__raskRef__":"id"}`, and both clients revive it to the live DOM element, so it
passes to `IJSRuntime` or to your scoped TypeScript as the element itself:

```csharp
var width = await Width(_box);   // RefDemo.ts's `export function width(el: HTMLElement | null)`
```

Focus an input, measure a box, and open a dialog from C#, then measure the box again in a sibling `.ts`:


---

## Wrapping a third-party JS library

Everything above is enough to wrap a library that owns its own DOM — a chart, a code editor, a map. Two
questions come up every time: **what happens to the DOM the library builds**, and **what happens to the
`<style>` it injects**. Rask answers the second for you; the first is one rule.

### Describing the library to TypeScript

There is no `node_modules` in a Rask app, so a library's own typings are not there to install. Write a
`.d.ts` beside your component describing **only what you actually call** — any `.d.ts` in the project is
compiled alongside your scoped files, and a narrow declaration that is true is worth more than a
complete one copied from upstream that drifts, because the compiler believes either equally.

A hand-written `.d.ts` beside the scoped file is a worked example: about fifty lines
covering one constructor, two methods and three callbacks. Rask's own globals (`window.DotNet`,
`window.Rask`) are already declared for you and need no work.

### Give the library a leaf to own

Render the host element with **no children** and let the library fill it. That's the whole rule, and it
works because of how the diff addresses nodes: ops are computed from your C# render tree and applied by
positional path, so a node your components never render is a node the diff can never reach.

```csharp
private readonly ElementRef _host = ElementRef.New();   // a field — the id must be stable across renders

// A leaf: no children here, ever. The library owns everything inside it.
protected override Component? Render() => Div.Ref(_host).Class("chart");

// Mount the library in OnFirstRender, not OnMount — OnMount runs *before* the first render, so the element
// doesn't exist yet and the ref would resolve to null. OnFirstRender runs once, so it never mounts twice.
// Mount and Update are Chart.ts's own exports, called as typed methods.
protected override async Task OnFirstRender()
{
    await Mount(_host, DataAsJson());
}

// Fires only on a real prop change — push new data at the library instead of re-mounting it.
protected override async Task OnUpdated() => await Update(_host, DataAsJson());

// Sync and fire-and-forget — see the note below on why this must not be an awaited DisposeAsync.
protected override async Task OnUnmount() => _ = DestroyQuietly();
```

There is one exception to "the diff can't reach it", and it is not optional. Not every frame is a diff:
the first interactive frame after page load always ships the body in full, and a structural change can
too. The client applies a full frame by **morphing** the document, and a morph pairs each live child
against the rendered one — your host has live children where the render says none, so the morph clears
it. Skip that and the chart is deleted seconds after it draws. Tag the library's nodes; the reconciler
leaves marked nodes alone:

```js
// Right after the library builds its DOM. Mark what it created — never the host itself, which your
// component *does* render (marking that makes the morph treat it as missing and append a duplicate).
for (const child of host.children) child.setAttribute("data-rask-managed", "");
```

One more identity rule, because it bites stateful wrappers specifically: a component's identity is its
**(type, position)** among its parent's children. A sibling rendered as `cond ? node : null` shifts every
later child's position when it vanishes, so the wrapper gets matched against the wrong slot and rebuilt —
remounting the widget on an unrelated click. Prefer disabling to un-rendering a sibling above a
stateful component.

For events coming back the other way, give your scoped export a **callback parameter** —
`mount(host, data, onSelect: (id: string) => void)` is `Mount(_host, data, id => _selected = id)` in C#. It
re-renders the component after it runs and is released when the component unmounts; nothing to register.

A script you do not own can't reach an instance method — the JS shim
dispatches to **static** `[JSInvokable]`s by assembly and name. Hand JS a token at mount, keep a static
`ConcurrentDictionary<string, YourComponent>`, route on it, and unregister on unmount. Two things to get
right, because a `[JSInvokable]` is callable by *any* script on the page with *any* arguments:

- **Make the token unguessable** (`Guid.NewGuid().ToString("N")`). That dictionary is static, so on the
  Server host it is shared by every live session — with a sequential `int`, one visitor could drive
  another's widget by counting from 1. Holding the token is what proves ownership.
- **Unregister on unmount**, or the entry pins the component for the life of the process.

Keep the boundary to primitives and JSON strings and a trimmed WASM publish stays clean. Prefer callbacks
that take **one** argument (bundle extras into a record): the generated chain step only wraps arity-≤1
delegates for auto-re-render, so a two-arg callback silently leaves the caller reaching for
`StateHasChanged()`.

Finally, tear down from `OnUnmount` **without awaiting** the interop call. An `IAsyncDisposable`
component is awaited by the framework's dispose walk, and that walk also runs for a session whose socket
has already closed — where an interop call has nobody to answer it and never completes.

> A scoped `{Component}.css` **cannot** style the library's internals: scoping works by stamping
> `data-{scopeId}` on the elements your component renders, and the library's nodes never get it. Size the
> host in scoped CSS; let the library's own stylesheet handle the rest.

### What it injects into `<head>`

Rask treats `<head>` as **authoritative**: on every re-render the live-diff reconciler morphs the live
head back to what your components rendered, which keeps `<title>`/`<meta>`/scoped-CSS links correct. A
`<style>`/`<link>`/`<script>` that a **JS library injects into `<head>` at runtime** (a code editor's
theme colours, a charting library, a syntax highlighter, an analytics tag) isn't part of that render —
so **Rask preserves it for you automatically**. The reconciler watches `<head>` and tags anything a
library injects with `data-rask-managed` (the same marker it uses for its own scoped-asset tags), so it
survives every re-render with **no code on your side**. A code editor keeping its injected theme across
a re-render is the usual case.

The mechanism only preserves nodes injected *after* an initial render (the common case — libraries set up
once your component has mounted). If you need to keep something present at first paint, or want to be
explicit, mark it yourself — the reconciler never touches a head child carrying `data-rask-managed`:

```js
// You rarely need this — runtime-injected head nodes are preserved automatically. Use it only to opt a
// node out explicitly (e.g. one present before the app's first render).
styleEl.setAttribute("data-rask-managed", "");
```

---

## An event that outlived its render

A click leaves the browser naming a handler by the id in the markup it was read from (`data-rask-on-click="h3"`).
The id is a position — the fourth handler its component rendered — so when an earlier button leaves or arrives,
the id names another handler. An event that left before a re-render and arrived after it used to run whichever
handler held the id by then.

**The rule: such an event reaches the handler it was sent to, or nothing. Never another handler.** Every event
says which page it was read from (`v`, a number the page carried when it was put on screen), and Rask finds the
handler that page had under that id as the page has it now:

- **It is still there** — in the same place, or moved because something before it left or arrived: it runs.
  A click on *Delete* sent just before *Cancel* disappeared still deletes.
- **It is gone** — its button, its row or its whole component left the page: nothing runs. The event is still
  answered, so a button waiting on it stops waiting, and no error is shown. The second click of a double click
  on a row that the first click removed does nothing.

"The same handler" is the same lambda or method of the same component. Where one lambda makes several handlers
in a render — a row's button, written once and rendered per row — the one an event was sent to is found by what
tells those handlers apart: the values they captured that differ from row to row. A value every row shares (a
view of the list the render worked out, a page number) is not which row it is, and may change between renders.
Captured values are compared for **equality**: the same instance, a type that says two instances are equal (a
number, a string, a record), or a plain object whose own fields are all equal.

What follows for a list of rows:

- **Capture what identifies the row.** `.OnClick(() => Remove(row))` works when `row` is the same object, an
  equal record, or an object with equal fields after a reload. A row whose captured object changed in any field
  between the two renders — its name was edited while the click was on its way — is not found, and the click is
  dropped rather than risked. Taking the id into a local first — `var id = row.Id;` …
  `.OnClick(() => Remove(id))` — captures only what never changes.
- **Do not capture a position.** `.OnClick(() => RemoveAt(i))` is the same handler in every render: after the
  first row is removed, `i` names the next row, and no rule can tell that it should not.
- **Key rows that are components** (`.Key(row.Id)`). A row that is a component of its own and carries no key is
  the same *instance* by position, whatever record it is handed — so after the first row is removed, the
  instance in second place shows the third record, and a click sent to "the second row" reaches it. A key ties
  the instance to its record, and that click then reaches its own row or nothing.

Three cases are worth knowing:

- **One button over "the current item"** — a lambda rendered once that captures the item on screen — is one
  handler whatever item it shows. A second click sent before the next item arrived acts on the item that is
  there now. A handler that must not run twice checks for itself (`if (item.Id != _current.Id) return;`), or
  renders one keyed button per item.
- **Older than two pages.** Rask remembers what a place held for the last two pages sent whose handlers moved —
  enough for a handler that awaits, which is rendered while it waits and again when it is done. A click that was
  on its way through three such renders (a page that ticks several times a second on a slow link) is dropped.
  Handlers that are methods of the component (`.OnClick(Save)`), and lambdas that capture only the component,
  are the same handler in every render and are never dropped for this.
- **A list longer than 4,096 handlers in one component** is not searched for a handler that moved.

A dropped event is counted in the `rask.handlers.stale` metric ([observability](https://rask.sh/docs/guides/observability/)), and in
Development it is logged on `Rask.Live` at Information, naming the component, the handler's position, the page
the event was read from and the page as it is.

Frames written by hand — a test, a script — that carry no `v` are read against the page as it is, as before.

## Behaviour hooks (`data-rask-*`)

Some behaviour can be written neither as a render (which only writes attributes) nor as a handler (which runs a
round trip later, after the gesture is gone): showing a popover under the pointer, writing the clipboard,
keeping a caret in place. For those the runtime carries small generic hooks. An element asks for one by carrying
an attribute; every hook is a delegated listener on the document.
[Rask UI](https://rask.sh/docs/guides/ui-kit/) is built on them, and they are just as usable from your own markup:
`Div.Data("rask-tooltip", "tip-1")[…]`.

They live in `src/Rask.Core/Resources/rask-hooks.ts` (one module per concern) beside the older ones in
`rask-dom.ts` — `data-rask-dismiss`, `data-rask-dismiss-after`, `data-rask-dismiss-hold`, `data-rask-focus-trap`,
`data-rask-popover-open`, `data-rask-dropzone`, `data-rask-shortcut`, `data-rask-contextmenu` — which are part
of the runtime itself.

### How the hooks load

**A page that carries none of these attributes does not download them.** The hooks in the tables below are a
script of their own, `rask-hooks.js` (49 kB, 15 kB gzipped), beside the runtime every page loads (`rask.js` on
the Server host, `rask.wasm.js` in a WebAssembly app). The runtime keeps only the list of attributes that ask
for a hook, and fetches the script the first time the page carries one — at most once per document:

| The attribute is… | What the network tab shows |
| --- | --- |
| nowhere on the page | `rask.js` only. No request for the hooks, however long the page lives. |
| in the page the **server rendered** | `rask.js` and `/rask/rask-hooks.js?v=…` side by side: the server writes the second `<script>` after the first in that response, so the hooks run straight after the runtime, as when they were one file. A dialog rendered open is a modal, and a remembered checkbox is restored, by the time the page has been read. |
| added later, by a render or an in-app navigation | one request for `rask-hooks.js`, when the attribute arrives. What the reader did to the page in between — a hover, a key, a press — is kept and handed to the hooks when they run: the tooltip under the pointer shows, the character typed into a one-time code moves on. Only what fires per pixel (a drag in flight, a chart's cursor) picks up at the next move. |
| in a **WebAssembly** app | `rask-hooks.js` from beside `rask.wasm.js`, requested when the runtime starts and finds one (a prerendered page usually has) or when a render adds one. |

Nothing is asked of you: there is no tag to write and nothing to register. Both scripts are served with the same
caching (one `?v=` names the pair on the Server host and both are immutable under it; a WebAssembly app serves
`rask-hooks.js` as it serves `rask.wasm.js`, and its service worker keeps it for offline use once fetched), from
your own origin, with the runtime's nonce when its `<script>` has one. Under a Content-Security-Policy the
hooks need what the runtime already needs — `script-src 'self'`, or the nonce — and no inline script.

Any element counts, whoever wrote it: your own markup, a `Raw` fragment, a node a script of yours inserted.
Besides the `data-rask-*` names, six of the platform's own ask for a hook, because a hook improves them
unasked: `popover`, `commandfor`, `aria-activedescendant`, `role="switch"` on a checkbox, `role="tablist"` and
`role="menu"`.

### What a hook module may import

For whoever adds a hook to the runtime: a hook module imports **other hook modules, `rask-bound.ts` and
`rask-owned.ts`, and nothing else**. The hooks are bundled apart from the runtime, and a bundle gets its *own
copy* of every module it imports. A hook that imported `rask-host.ts` to ask `inRoot()` would be asking a copy
no host was ever installed in; one that imported `rask-morph.ts` or `rask-loading.ts` would read dirty fields
and loading buttons nobody recorded there. What the two bundles must agree on hangs off one object on the
global, `__raskHookSeam`, declared in `rask-owned.ts` — the only module both carry, which itself imports
nothing. Something new a hook needs from the runtime goes on that seam.
`HookBundleContractTests` fails for a second module in both bundles.

The runtime itself **runs a document once**. Its shared modules bind their document listeners as the script is
read, so a second copy in the same document — the Server host's tag is a child of the render root, and a
navigation to a page with more or fewer top-level nodes puts it back — is never run by the morph, and a copy
that arrives some other way stands down: its listeners answer nothing and send nothing
(`standDown` in `rask-host.ts`).

### Pointer-opened popovers

| Attribute | On | What the runtime does |
| --- | --- | --- |
| `data-rask-tooltip="<popover id>"` | the element wrapping a trigger and its `popover="manual"` bubble | Shows the bubble in the pointer's own task when it enters the wrapper and hides it when it leaves; shows it on keyboard focus (`:focus-visible`) and keeps it while that focus lasts; Escape hides it; a press on the trigger hides it until the pointer has left and come back. The element inside carrying `aria-expanded` has it kept true or false. Any element can be the trigger — the bubble reaches the top layer. A touch never hovers. |
| `data-rask-hover="<popover id>"` | the element wrapping a trigger and its panel | Opens the panel at once when the pointer comes over the wrapper (trigger or panel). It closes the way Flux UI's does: a pointer move is looked at no more than once in 100 ms, and the panel closes when the move looked at is over neither. A pointer that rested and then moves into the gap closes it with that move; one that is moving crosses a narrow gap unseen and arrives. Nothing looks again by itself, so a pointer that stops outside after a move that was passed over leaves the panel open until it moves again. A press on the trigger's own `popovertarget` button leaves it open; Enter opens it the platform's way; focus alone does not. A panel the pointer opened takes no focus, even one that carries `autofocus`: focus stays where it was — and when the pointer leaves and it closes, focus is handed to nobody. |
| `data-rask-open-mark` | a `[popover]`; or, as `data-rask-open-mark="<popover id>"`, any other element | `data-open` on the popover — or on the element that names it — while the popover shows: written in the task that opens it and removed in the one that closes it, however it was opened, and held against the render. `Ui.Dropdown` names its panel on itself and on its trigger, and `Ui.Context` on its area, so an open menu or popover is drawn without the page being told it opened. What a stylesheet lays an open panel out by (`data-open:flex`) without waiting for the page to hear the toggle. `Ui.Popover` writes it. A dialog run by `data-rask-modal` gets the same mark with nothing to ask, and a `<dialog>` is a panel here too: what names it, and its invokers' `aria-expanded`, follow it (`Ui.ColorPicker`). |
| `data-rask-hover-if="<selector>"` | the same element | The hover opens only while the element matches the selector — a rail item that opens its menu only while the sidebar is collapsed: `"input:checked ~ *"`. |
| `aria-expanded` on the trigger inside a `data-rask-tooltip` | an interactive tooltip (one whose bubble holds links or buttons) | Mirrored while the bubble shows. It also marks the tooltip as interactive: when focus drops to nothing (`blur()`, the window losing focus) the bubble stays, until a press outside it. A tooltip without it closes. |

### Popovers, dialogs and the page behind them

**Open and shut has two hands, and the reader's comes first.** A popover with `data-rask-popover-open`, a
dialog with `data-rask-modal-open` and a `<details>` whose `open` the page renders are opened and shut by a
render — and by the reader, in the browser, at once. From the moment the browser opens or shuts one until the
server has answered that event (its `toggle`, or a dialog's `close`), no render drives it: a reply rendered
before the server heard Escape cannot open the list again. What a render was refused meanwhile is carried out
once the answer is on the page. And the toggle the runtime raises by opening or shutting one to match a render
is not sent back: `OnToggle` and `OnBeforeToggle` hear what the reader did, not the page's own change of mind.
A dialog's `OnClose` hears every close, the page's own included.

| Attribute | On | What the runtime does |
| --- | --- | --- |
| *(none)* | any `popover` / `popover="auto"` | When focus leaves an open one for anything but its own `popovertarget` button it closes, and so it does when focus leaves that button for anything but the popover; when a press outside closed it and focus is on `<body>`, focus goes to that button. A `popover="manual"` is left alone. |
| `data-rask-modal-open="true"` \| `"false"` | a `<dialog>` | `showModal()` / `close()` to match, when the attribute changes or the dialog arrives — a real modal with a `::backdrop`, where a rendered `open` is not. Removing the attribute closes it too. The dialog's own `close` and `cancel` events fire as usual. |
| `data-rask-modal="any"` \| `"press"` \| `"escape"` \| `"none"` | a `<dialog>` | How a reader dismisses it: Escape and a press outside, a press outside only, Escape only, neither. A press outside fires a cancelable `cancel`, then closes. With no value the dialog is the platform's. |
| `data-open` *(written)* | a dialog carrying either attribute above | Present while the dialog is shown, however it was opened or closed. |
| `command` / `commandfor` | a `<button>` | Where the engine has no invoker commands: `show-modal`, `close`, `request-close`, and the popover three. |
| `data-rask-toggle="<popover id>"` | any element that is not a `<button popovertarget>` | A click on it toggles that popover — unless the click is in a text field, a `<select>` or an editable element inside it, which takes it for itself — and so do Enter and Space while it — or something inside it that is not a control of its own — has focus (Space does not scroll). A `popover="manual"` it opened is closed by Escape, by a press outside both, and by focus moving outside both; a press inside the popover leaves it open. A `popover="auto"` gets those from the platform, and a click on its toggle closes it rather than reopening it. Focus is yours: give the element a `tabindex` and a `role`. Do not also write `popovertarget`. |
| `aria-expanded` *(rewritten)* | an element that invokes a popover (`popovertarget`, `commandfor`, `data-rask-toggle`) and already carries `aria-expanded` | `"true"` while that popover shows and `"false"` when it does not, however it was opened or closed. Never added where the render did not write it; once written, a render no longer overwrites it. |
| `data-rask-lock` \| `data-rask-lock="scroll"` | a popover or dialog | While it is open `<html>` does not scroll, keeps its scrollbar's gutter where a scrollbar was showing (`data-rask-gutter`, written; none is reserved where none was, so the page stays exactly as wide and scrolled exactly as far as it was) and — unless `"scroll"` — takes no pointer; the overlay itself stays usable. Counted, so two open overlays, one removed by a render, and a navigation all end unlocked. |
| `data-rask-place="<side> <align> <gap> <offset>"` (`"bottom start 5 0"`) | a popover that names its anchor (`position-anchor` in its `style`, against an element whose `style` says that `anchor-name`); one whose style names none is placed against what shows it — the first element of the `data-rask-tooltip` or `data-rask-toggle` that names its id, or its `popovertarget` button — and one nothing names sits at the pointer of the right-click that opened it, where it is turned and slid but never held to a height | Keeps the open panel inside the viewport, as Flux UI's script does: the side asked for, then the other alignment, then the opposite side — the first that fits, or the roomier one — then slid back to stay 5 px inside the edge it runs along, and held to the height that is left, scrolling inside. The CSS anchor rules stay what places it with no script. Written as one rule per open panel in a stylesheet of the runtime's own, keyed on `data-rask-placed` *(written, held against the render)*; placed in the task that opens it and before the next frame, then once a frame while something scrolls, the window is resized or the panel or its anchor changes size. Nothing is sent to the page. Every anchored panel of `Rask.Ui` writes it. A panel whose `--rask-place` custom property computes to `none` is left to the stylesheet for as long as it does — `max-sm:[--rask-place:none]` is how `Ui.DatePicker`'s popup, a sheet centred over the page under 640 px, is not placed there; asked again on every pass, so a resize across the breakpoint hands it over. |

### Menus

| Attribute | On | What the runtime does |
| --- | --- | --- |
| `role="menu"` | an element holding `[role=menuitem]` / `menuitemcheckbox` / `menuitemradio` rows | The navigation keys (arrows, Home, End, Page keys, Space, Enter) do not scroll the page — your key handler still receives every one. A row that gains `data-active` inside an open popover is focused — unless it says `tabindex="-1"`, which is how a row the POINTER lit is told from the keyboard's — and so is the row a render gives `tabindex="0"`. Enter and Space click the focused row, or the one `aria-activedescendant` names, unless it opens a submenu. ArrowDown on a closed `[popovertarget][aria-haspopup]` button opens its menu and hands the menu the key. A click on a row closes the popover the menu is in; Tab out of it closes it; and a popover that closes with focus nowhere hands it to its button, unless it is a `data-rask-hover` panel the pointer left. |
| `data-rask-keep-open` | a row, or anything around it up to the menu | A click on the row leaves the popover open. |
| `data-rask-menu-cursor` | the outermost `[role=menu]`, whose rows are rendered with `tabindex="-1"` and no `data-active` | **The runtime keeps the menu's cursor, and the page is sent nothing while the reader moves about it.** Lit row (`data-active`), focused row (`tabindex="0"`, and focus) and open flyouts (`data-open` on the element holding a submenu's row and the `[role=menu]` right after it) are written in the browser and held against the next render. Keys: with focus on the menu either arrow lands on the row the pointer lit, or the first; from a row the arrows count from the LIT row, a row at a time, stopping at the ends and over `disabled` / `aria-disabled` rows; ArrowRight or Enter on a submenu's row opens it onto its first row, Space opens it and stays, ArrowLeft closes the flyout the cursor is in; a letter jumps to the row that starts with it (letters within 500 ms are a prefix; the same letter again goes on to the next); Home, End and the page keys do nothing. Pointer (with `data-rask-menu-pointer`): entering a row lights it, opens its flyout, and closes every flyout the row is not inside; a flyout stays open after the pointer leaves the menu. A press puts the cursor on its row; on a submenu's row a tap opens the flyout and the next closes it. All of it is forgotten when the popover closes. Do not render `data-active`, `tabindex="0"` or `data-open` yourself, and write no key or pointer handler: your page hears the rows' own clicks. |
| `data-rask-menu-pointer` | a `[role=menu]` | The row under the pointer gets `data-active` at once and every other row of that menu loses it; once the pointer leaves the menu none has it but the row that has focus, which is the keyboard's. Focus is not moved. A flyout is a menu of its own: give it the attribute too. With `data-rask-menu-cursor` there is nothing else to do. A menu whose cursor your PAGE keeps instead moves it from the row's `OnPointerEnter` — a round trip per row — and renders the lit row with `tabindex="-1"` until the keyboard takes it. |
| `data-rask-safe-area="<flyout id>"` \| `data-rask-safe-area` | a row that opens a submenu; with no value the flyout is the element right after the row | While the flyout is showing, the triangle between the pointer and the flyout's near edge belongs to the row, so the diagonal towards the flyout does not touch the rows it crosses. |

### Fields

| Attribute | On | What the runtime does |
| --- | --- | --- |
| `data-rask-copy="<id>"` | a button | Copies that element's value (or text) in the click itself, then carries `data-copied` for 2 s. |
| `data-rask-view="<id>"` | a button | A click shows that password field as text and the next hides it again; the button carries `data-viewable-open` while it shows. Both are held against the render and nothing is sent. `Ui.Input.Viewable()` writes it. |
| `data-rask-clear="<id>"` | a button | Empties that field, fires `input` and `change` on it, and focuses it. |
| `data-rask-clear-keys="Escape"` | an `<input>` or `<textarea>` | A listed key (`KeyboardEvent.key`, a space between) pressed in the field empties it, in the browser and at the key. Your `OnKeyDown` hears the key; no `input` is fired. Empty it from the handler instead and it loses what was typed during the round trip. |
| `data-rask-focus="<id>"` | any element | Focuses that element after a click. |
| `data-rask-offline` | `<html>`, written by the runtime — never by hand | Livewire's `wire:offline`. On while the browser reports itself offline (`navigator.onLine`, and the `online` / `offline` events) and, on the Server host, while its socket is down (until it is open again); off otherwise. Both hosts. For style: `hidden in-data-rask-offline:block`. Nothing is sent to the page. |
| `<script type="application/json" id="rask-texts">` | the first response's `<body>`, written by the Server host — never by hand | The reconnect overlay's eight texts (`ConnectionReconnecting` … `ConnectionSessionTimedOut`) as a JSON array, in the page's culture. Written with the first response only, so a live update neither builds nor sends it; the runtime reads it when the overlay shows, and a page without it shows the English. A language switched without a reload is not re-read. |
| `data-rask-confirm="<question>"` (+ `data-rask-confirm-expect="<answer>"`) | an element with `data-rask-on-click`, a `<form>` with `data-rask-on-submit`, or its submit button — written by `.Confirm(…)` | Livewire's `wire:confirm`. Before the click or the submit leaves the browser, the question is asked in `confirm()` — or, with `-expect`, in `prompt()`, and only an exact, case-sensitive answer goes. Refused, nothing is sent: not the event, not the controls waiting for the next action, and the default action (a popover, a command) is cancelled too. A submit asks the form first, then its submit button, before the form is held or anything uploads. Not a hook: both runtimes ask it in their own click and submit listeners. With `Ui.Confirm` on the page the question is asked in that dialog instead (`rask-confirm-dialog.ts`, a hook, answering through the seam): the action is refused at once, and Yes makes it again — `el.click()`, or `form.requestSubmit(submitter)` — which is then let through. Opening, typing and closing send nothing. |
| `data-rask-confirm-dialog="message \| field \| go"` | the parts of `Ui.Confirm`'s dialog — written by the kit, never by hand | Where the question is written, the box an `expect:` answer is typed into (shown only then; Yes is disabled until it matches exactly, and Enter there is Yes), and the button that says yes. Every other way the dialog closes is no. `data-rask-leave="message \| go"` are the same two parts of `Ui.ConfirmLeave`'s dialog; both are opened by `rask-ask.ts`. |
| `data-rask-bind-on="action"` | a bound control with `data-rask-on-change` and no timing step — written by a plain `.Bind(…)` | The control waits for the next action. Typing into it, choosing in it and leaving it send nothing: its own `change` is not sent. The runtime remembers it, and whatever a host sends next for the reader — a click, a submit, a handled key, another control's change, a navigation, what a behaviour hook raises for a key or a press — is preceded by the `change` of every control remembered, in the order they were last touched, each once. What the runtime does by itself carries nothing ahead of it and is sent with `"unasked":true`, so the page writes nothing its own controls hold either: a toast's timed dismissal (`data-rask-dismiss-after`), a measured size, the drawer a navigation unchecks, a `scroll`, a media, animation, transition, `load` or `error` event, focus a render moved, a dialog the runtime closed, a field restored after a reload, a `jsResult`, a `DotNet.invokeMethodAsync` call. Of a radio group only the one left checked is sent. A render that knows nothing newer than the reader does (a push, the reply to another event) leaves what they typed or chose where it is, focused or not. |
| `data-rask-debounce="<ms>"` | a field with `data-rask-on-input` — written by `.Live()` (150) and `.Debounce(…)` | What is typed is sent once typing has paused that long, as one `input`. Sent at once, ahead of it, by anything that has to follow the value: a submit, a click or key handler, a navigation, and the `change` of leaving the field. No pause is counted while a character is being composed (IME). Not a hook: the runtime's own input listener reads it, and on WASM a pause that ends while .NET still owes the last answer is held like any typed value. |
| `data-rask-on-pause="<handler>"` | a field with `data-rask-debounce="<ms>"` — written by the `Debounce(…)` of `Ui.SelectSearch`, `Ui.SelectInput`, `Ui.PillboxSearch`, `Ui.PillboxInput` and `Ui.Autocomplete` | A handler that hears the pause, where the row above is a bound value sent at one: a search field that asks a store. It is sent what the field says once typing has paused that long (`type: "input"`), and nothing else sends it — not the next action, not leaving the field. No handler reads a search before it runs, and sent ahead of the press on a result it would redraw the list under that press. A `data-rask-on-input` beside it hears every key, as if no pause were counted. No pause is counted while a character is being composed. |
| `data-rask-bind-on="blur"` | a field with `data-rask-on-change` and no `data-rask-on-input` — written by `.Blur()` | As `"action"`, except that the `change` the browser fires on leaving the field sends it too, whichever comes first. The `change` fired afterwards for a value that already went is not sent twice. |
| `data-rask-draws="<events>"` | an element of a control that draws itself on the page's side, beside its `data-rask-on-<event>` handlers — written by the kit, never by hand | The events named (`toggle`, `click`, `keydown`, `input`, `change`, `mouseenter`, …, by event name with a space between) are heard only to draw the control again: a list opened, the row under the pointer, the next month, what is typed into a list's search, the pick of a bound control that waits. Each is sent alone and marked `"unasked": true`: nothing that waits for the reader's next action is sent ahead of it, and the page writes none of what its own controls hold either. An event of the same element that is not named is an action as always. |
| `data-rask-on-edit="<handler>"` | a waiting field while a validation message shows under it | The first keystroke is reported, once, as an `edit` with no value: the page takes the message away and runs no rule. The attribute goes with it. |
| `data-rask-on-edit="<handler>"` | a waiting field, only while a message shows under it | The first `input` in the field is reported once (`type: "edit"`, no value) and the attribute is removed; C# takes the field's messages away and runs no rule. A valid field does not carry it. |
| `data-rask-mask="(999) 999-9999"` | an `<input>` | Shapes what is typed: `9` a digit, `a` a letter, `*` either, anything else itself. The handler receives the shaped value; the caret stays where the reader was typing. |
| `data-rask-mask-money` \| `=".,2"` | an `<input>` | Groups an amount: decimal mark, thousands mark, decimals. |
| `role="switch"` | an `<input type="checkbox">` | Enter toggles it, as Space does. |
| `data-rask-big-step="<n>"` | an `<input type="range">` | Shift+Arrow and PageUp / PageDown move by `n`, firing `input` then `change`. |
| `data-rask-otp` \| `="alpha"` \| `="alphanumeric"` | the group around one-character inputs | The cells behave as one field: a character moves on, Backspace walks back, deleting closes up to the left, the arrows stop at the first empty cell, a paste fills from the first. Render the cells with no `value` and no handler, and bind ONE `<input type="hidden">` inside the group: the runtime keeps it equal to the code and fires `input` and `change` on it, and refills the cells when you change its value. |
| `data-rask-segments` + `data-rask-segment="month"` \| `"day"` \| `"year"` \| `"hour"` \| `"minute"` \| `"meridiem"` | the group around the small inputs of a typed date or time, and each input | The parts behave as one field: digits only, zero-padded; a part that can take no further digit moves on (3 is March, 9 is nine o'clock); ArrowLeft / ArrowRight walk the parts and stop at the ends; ArrowUp / ArrowDown step a part and wrap; Backspace empties a part and, on an empty one, steps back; `a` / `p` set the meridiem; a pasted date is shared out; a day the month does not have becomes its last; a one- or two-digit year is read within twenty years ahead. Render the parts in your locale's order with a `placeholder`, NO `value` and NO handler, and bind ONE `<input type="hidden">` inside the group: it carries `yyyy-mm-dd`, `HH:mm` (24-hour) or the two joined by `T` once every part is there, fires `input` and `change`, and refills the parts when you write it. A `readonly` part is left alone. |

### Keys and focus

| Attribute | On | What the runtime does |
| --- | --- | --- |
| `data-rask-contain-keys="Arrows Home End PageUp PageDown"` | a widget that handles those keys in its own handler | Cancels the browser's default for a listed key pressed inside it — the page does not scroll behind a calendar, Enter does not press a trigger — and nothing else: your handler still receives the key. Names are `KeyboardEvent.key`'s, plus `Space` and `Arrows` (all four). A key held with Ctrl, Alt or Meta is left alone, and so is one typed into a text field inside the widget. It is a list, not a rule per role, because no two widgets keep the same keys; render a different list when the widget's keys change (open / closed). |
| `data-rask-keys="ArrowDown ArrowUp Enter Escape Tab"` | an element with `OnKeyDown` or `OnKeyUp` | The handler hears only the listed keys (`KeyboardEvent.key`, a space between); no other is sent. A combobox acts on five keys, and without the list every letter typed into it is a round trip and a render of its own ahead of the `input` that follows. A key that is not listed does not flush a `.Debounce(…)` field either; one that is sent does, so an unfiltered `OnKeyDown` on a debounced field sends the value at every key. A key written `Backspace:empty` is heard only from a field that holds nothing (a pillbox's input, where it takes the last pill off). Not a hook: the runtime's own key listener reads it. |
| `data-rask-list="<words>"` | the root of a control that opens a list of options: it holds what opens the list (a button, a text input, or an element around an input, saying `aria-haspopup="listbox"`), one `[popover]` that is or holds the `[role=listbox]`, and the rows — `[role=option]`, each with an id | **The runtime keeps where the reader is in the list, and the page is sent nothing while they move about it**: the lit row (`data-active`, and `aria-activedescendant` on the `role="combobox"` that takes the keys), whether the list is open (`data-open` and `aria-expanded`, in the task it opens), and — with `filter` — the rows a search leaves (`data-hidden`), all held against the next render. Opening lights the first `aria-selected="true"` row, or the first; typing lights the first row left. ArrowDown / ArrowUp go a row at a time over `aria-disabled` and hidden rows and stop at either end (with no row lit: the first, or the last); Home, End and the page keys do nothing; Enter CLICKS the lit row, so your row's own `OnClick` is the pick; Tab closes; on a list with no text field a letter jumps to the next row that starts with it (letters within 500 ms are a prefix) and on a CLOSED one clicks it. The pointer lights the row it enters — not one that slid under a pointer at rest — and that row goes dark when the pointer leaves the list, where one the keys lit stays. With focus on the list itself the keys do nothing. The words: `multiple` (a pick leaves the list open and empties what was typed, unless `stay`), `filter` (typing hides the rows whose text and `data-rask-list-keywords` do not hold it, whatever the case or the accents; without it your page answers the search), `strict` (a closed text input says the `value` it was rendered with again), `clears` (a closed text input is emptied), `text` (the answer is the input's text: a pick writes the row's words into it and raises `input` and `change`, so bind the input itself), `top` (after a pick emptied the search the cursor returns to the first row). On a row: `data-rask-list-text` (its words, where its text holds more), `-keywords`, `-says` (what a `strict` input says once it is picked), `-keep` (no search hides it), `data-rask-list-create="<min length>"` (the row offered once what is typed is that long and names no option; no value: always there). Inside the list: `data-rask-list-empty` (shown while no row is), `data-rask-list-group="<rows>"` (a heading hidden with the rows after it), and `data-rask-list-remove` on a pill's cross (Backspace in the empty input presses the last). Do not render `data-active`, `data-open`, `aria-activedescendant` or an open `aria-expanded` yourself, and write no key, pointer or toggle handler. |
| `data-rask-color="<format>"` | the root of `Ui.ColorPicker` (`hex` `hexa` `rgb` `rgba` `hsl` `hsla`), its FIRST hidden input the one field you bind, each part named by `data-rask-color-part` (`area`, `hue-layer`, `hue`, `alpha`, `fade`, `text`, `popover-text`, `button`, `opener`, `preview`, `value`, `placeholder`, `swatches`, `clear`, `dropper`, `announce`, `select`, `sheet`), the words it writes in `data-rask-color-says` (JSON) | **The runtime keeps the colour picker and the page hears only the colour**, when Flux's element announces one: `input` and `change` for each key of the area (2, 10 with Shift; the page keys and Home / End to its edges) and of a slider (1, 10 with Shift), `input` on every move of a drag and `change` when it lets go, the colour typed into either text field when that field changes (Enter, which also leaves it, or leaving it; empty clears), a swatch at a press or on Enter / Space, the dropper's colour. Opening, closing, the swatch walk (eight to a row, stopping at the edges), the live region and the opener's name say nothing. On the phone sheet (the `sheet` box is two pixels wide) everything waits for Select but a swatch, which is taken and closes the sheet. Text that is no colour is put back, or announced as it is when the root says `data-rask-color-refuses`, for the page to refuse. Render the parts from the colour the page holds and write no handler on them. |
| `data-rask-calendar="<json>"` | the root of `Ui.Calendar` — or of `Ui.DatePicker`, around its trigger and its popup — its FIRST hidden input the one field you bind (`yyyy-mm-dd`, days joined by commas, a range as `start/end` with a preset's key after a third slash, nothing for none); the JSON says the mode, today, the limits, the week, the culture's names and .NET date patterns and the classes each state is drawn with; parts by `data-rask-calendar-part` (`header` and `months`, which the page renders once and never diffs again, `previous`, `next`, `today`, `month`, `year`, `narrow`, `text`, `presets` — rows with `data-rask-calendar-range` — `preset-select`, `confirm`, `clear`) | **The runtime draws every month after the first paint, and the page hears only the value**, when Flux's element announces one: a day picked (`input` and `change`), the same day again clears it, of several a day is added or taken out, a range's last day writes the range — its first day, and the stretch the pointer or the arrows draw to the end, announce nothing. The steps, the header's selects and the paging keys page the month; Home / End / PageUp / PageDown leave the focus to the page; the arrows walk the days over what cannot be picked, paging with them. A pick that finishes the choice closes a date picker's popup, unless it confirms or the popup is the phone's sheet (the `narrow` box two pixels wide), where the choice waits for `confirm`; Cancel, Escape and a press outside drop it on a desktop and keep it on a phone. ArrowDown / ArrowUp on the closed button open the popup. A value your page writes into the field is drawn. Render no handler on any part. |
| `data-rask-listbox-button` | a `button[role=combobox]` | The list `Enter ArrowUp ArrowDown`, while `aria-expanded` is not `true`: Enter does not press it and the arrows do not scroll the page. |
| `data-rask-roving` | a `[role=radiogroup]` of `[role=radio]` elements that are not native radios | ArrowDown / ArrowRight focus the next radio and ArrowUp / ArrowLeft the one before, wrapping at both ends; the radio focused is clicked, so your `OnClick` selects it; `tabindex` moves with it (`0` on the focused one, `-1` on the rest). Space clicks the focused one. Disabled radios are skipped. |
| `role="tablist"` | an element holding `button[role=tab]`s | ArrowRight / ArrowDown focus the next tab and ArrowLeft / ArrowUp the one before, past a `disabled` tab and around the ends; the tab arrived at is clicked, so your `OnClick` selects it (or the runtime does, below), and the page does not scroll. From a button in the row that is not a tab the arrows start at the `aria-selected="true"` one. Home and End are left to the page. Only buttons take part. |
| `data-rask-command` | the root of a command palette, holding its search `<input>` and a `[role=listbox]` of `[role=option]`s, with a `[data-rask-command-empty]` row (`Ui.Command` writes it) | Flux's palette, kept in the browser. Focus in the box selects its text and lights the first command shown; focus leaving it lights none. Typing hides, by `data-hidden`, the options whose `data-rask-match` (their text, without one) does not contain it — whatever the case or the accents — shows the empty row while none is left, and lights the first one left. ArrowDown / ArrowUp move the lit command among those shown and stop at the ends; the pointer lights the one under it, and leaving takes away a light the pointer gave. Enter clicks the lit command (and never submits a form); a pressed command empties the box. The lit command is `data-active` on it and `aria-activedescendant` on the box. Nothing is sent; all of it is held against the render. |
| `data-rask-tabs` | a `role="tablist"` whose tabs each say their name in `data-rask-tab` (`Ui.Tabs` writes both) | Selection is the browser's: a press on a tab — or an arrow arriving at it, or find-in-page about to reveal its `hidden="until-found"` panel — writes `aria-selected`, `tabindex` and `data-selected` on the tabs and `hidden`, `tabindex` and `data-selected` on the panel each tab's `aria-controls` names, all held against the render, and sends nothing. When the tablist's FIRST child is an `<input type="hidden">`, the tab's name is written into it and announced with `input` and `change`, so it is bound like any field (a plain `Bind` waits for the next action). The value is `"<turn> <name>"`, the tab the page says is selected: a change within one turn answers what the browser told it and is not drawn over a later choice; a new turn is the page choosing a tab itself, and is shown. Paint the selected tab from `[data-selected]`. |
| `data-rask-focus-follows` + `data-rask-focus-target` | a container, and the one element in it that should hold focus | When a render moves the target mark — or replaces the element that carried it — while focus is ON that element, the new target is focused. Focus anywhere else is never taken. A render that leaves no target in that container lets focus fall where the browser drops it — another container's target is never taken, so two calendars on a page keep to themselves. (No kit calendar asks for it any more: `data-rask-calendar` keeps its own focus.) |
| `aria-activedescendant` | a `[role=combobox]` or `[role=listbox]` | When it changes, the option it names is scrolled into view inside its nearest scrolling ancestor, by the least movement; the page never scrolls. (A `[role=tree]` has its own rule, which also handles virtualized rows.) |
| `data-rask-press-keeps-focus` | any element | A mouse press on it or inside it does not move focus. |

### Gestures, capabilities and uploads

| Attribute | On | What the runtime does |
| --- | --- | --- |
| `data-rask-drag="x y"` \| `"x"` \| `"y"` | a surface holding ONE `<input type="hidden">` you bind | On a press the runtime captures the pointer and, on every move, writes where it is along each named axis — `0` to `1`, clamped, `0` at the left / top edge — into `--rask-drag-x` / `--rask-drag-y` on the surface, with no round trip: draw the thumb from those. The hidden field carries the same numbers (`"0.25 0.5"`, or the one axis) and fires `input` at most once per animation frame while the pointer moves and `change` once on release, so `OnInput` / `OnChange` on it are the whole C# side. Write the field's value yourself (a key handled in C#) and the properties follow, except while the surface is held. Give the surface `touch-action: none`. |
| `data-rask-drag-inset="<px>"` | the same surface | The track is that much shorter at both ends, so a thumb centred on the value stays inside the surface. |
| `data-rask-requires="<global>"` | a control for an API the browser may lack (`EyeDropper`) | `hidden` is set where `window` has no property of that name and removed where it has, when the control arrives. Render it `hidden`. The name must be a plain identifier; nothing is evaluated. |
| `data-rask-plot="0 0.25 0.5 1"` with `data-rask-plot-area`, `data-rask-plot-row="<index>"`, `data-rask-plot-text`, `data-rask-plot-tooltip="<px>"`, `data-rask-plot-frame` | a chart's root: each row's place from 0 to 1 across the area; the element whose box is the plot (it may carry the places itself, `data-rask-plot-area="0 0.25 …"` with the root's attribute bare, and `data-rask-plot-axis="y"` when the rows run down it); everything that belongs to one row; an element that reads differently per row, the attribute holding every row's text, one per line; an absolutely placed box; the box that one is kept inside (the root without it) | While the pointer is inside the area the root carries `data-active`, `--rask-plot-x` (the nearest row's x; `--rask-plot-y` when the rows run down), `--rask-plot-at` (that row as a fraction of the area), `--rask-pointer-x` and `--rask-pointer-y` (px from the root's corner); every element of the row nearest along the axis carries `data-active` — the switch is at the midpoint between two rows; each `data-rask-plot-text` shows that row's line, and what was rendered again when the pointer leaves; the tooltip carries `data-active` and `transform: translate(x, y)`, beside the row along the axis and beside the pointer across it, each `<px>` away and flipped to the other side when the box would pass the frame's right or bottom edge. A root with NO area is a plot of shapes (a pie): the row is the `data-rask-plot-row` element under the pointer, every other row's elements carry `data-inactive`, and the tooltip follows the pointer both ways. Outside, the marks come off. No round trip: style `[data-active]`. |
| `data-rask-measure` | an element holding ONE `<input type="hidden">` you bind | The field is kept at `"<width> <height>"` of the element's content box, in CSS px, with `input` and `change`: when the element arrives, and after that once its box has stopped changing for 100 ms (a `ResizeObserver`; a redraw per frame of a window being dragged would be a round trip per frame). Nothing is announced for an element with no box (hidden), or for a box within half a pixel of what the field already says — render the size you drew at into the field and a chart drawn at its real size is told nothing. What a chart needs to redraw at its real size. |
| `data-rask-loading` around an `<input type="file">` with `OnFiles` | the dropzone | From the moment files are chosen until the handler that receives them has rendered, the element carries `data-loading`, `--rask-progress` (a whole percentage, `12%`) and `--rask-progress-as-string` (`'12%'`, for `content:`). On the Server host the percentage is the upload request's own progress. In a WebAssembly app nothing is sent: it is how much of the files your handler has read through `OpenReadStream`, and stays `0%` for a handler that never opens them. `data-rask-loading="off"` opts out. |
| `data-loading` on a field | an `<input>`, `<textarea>` or `<select>` whose `input` or `change` event was sent (`.Live()`, `.Debounce(…)`, `OnInput`, a change) | Livewire's `data-loading` on a `wire:model.live` input: written after 200 ms, until that event's ack (Server) or dispatch promise (WASM) — the release a pressed button's mark has. `data-loading` only, no `aria-busy`, nothing around it; a field that waits for the next action sends nothing and is not marked; `data-rask-loading="off"` on it or above opts out. Held against the render like a button's. |
| `data-rask-loading` \| `"off"` \| `"within"` | a control; a container | **Bare**, on an element that carries a handler: it waits like a button — `data-loading` and `aria-busy="true"` once its dispatch has gone 200 ms unanswered, a second press dropped until it is. **`"off"`**, on a control or anything above it: none of that. **`"within"`**, on a container: every dispatch that starts *inside* it marks the container with `data-loading` after the same 200 ms — and nothing else: no `aria-busy`, no refused press. The nearest one above the press is marked, the pressed control keeps its own mark, and the mark outlives the render that answers the press, so it works where the control itself is replaced (a pager's page number). All of them end when the dispatch does: on the Server host with the handler's ack, which is sent once the reads the page went without are back; in a WebAssembly app when the dispatch and those reads have returned. Style it with `[data-loading]` / `has-data-loading:` — the model is [Livewire's `data-loading`](https://livewire.laravel.com/docs/4.x/loading-states). |
| `data-rask-on-submit` (a `<form>` with `OnSubmit`) | the form's own controls (`form.elements`) | From the press until that submit is done (the same end as the loading mark), `disabled` on every submit button and `readonly` on every text field and textarea that the render had not already marked; a submit that reaches the form meanwhile — a second press, Enter, `requestSubmit()` — is dropped, never sent. Both are held against the morph, and each control is left with what the last render said of it. The model is [Livewire's `wire:submit`](https://livewire.laravel.com/docs/4.x/wire-submit). |

### Toasts

| Attribute | On | What the runtime does |
| --- | --- | --- |
| `data-rask-dismiss-hold="pointer"` | an element with `data-rask-dismiss-after` | Only the pointer holds its countdown; focus inside it does not. |
| `data-rask-dismiss-scope` | an ancestor of several | The pointer anywhere over it holds every countdown inside, and they run on from where they stopped. |
| `data-rask-stack` | the element whose children are stacked | Each child gets, in its style, `--rask-stack-index` (0 at the front, the last child), `--rask-stack-height` (its own natural height), `--rask-stack-offset` (the natural heights in front of it) and `--rask-stack-front` (the front child's natural height), measured again when a child joins or leaves and when the window is resized — what a stylesheet needs to cut every card to the front one's height and glide the stack open. While it measures, the stack carries `data-rask-measuring`: write the rule that cuts a card as `[data-rask-stack]:not([data-rask-measuring]) …`, or the hook is handed the cut height back. |

### State the reader owns

| Attribute | On | What the runtime does |
| --- | --- | --- |
| `data-rask-persist="<key>"` | a checkbox | Checked as the reader last left it, from `localStorage[key]` (`"true"` / `"false"`), and stored on every change; a render never resets it — the runtime every page loads leaves the box alone from the start, whether or not the hooks have arrived. The hooks restore it when they load — in a WebAssembly app that is after boot, so a page that must not flash restores the same key from a script of its own in `<head>` (`Ui.SidebarScript` does for a sidebar), and that script is enough on its own: nothing puts the box back before the hooks come. |
| `data-rask-uncheck-on-navigate` | a checkbox | Unchecked (with a `change` event) when the app navigates to another path, and on a press on a link to the page the reader is already on (same path and query, no fragment), which navigates nowhere. |
| `data-rask-confirm-leave="<message>"` | a `<form>` — written by [`Form.Model(m).ConfirmLeave("…")`](https://rask.sh/docs/guides/forms/#ask-before-leaving-unsaved-changes) | From the first `input` or `change` inside it the form is unsaved, and a nav link, `__raskHost.navigate`, Back and Forward ask first — with `confirm(message)`, or in the dialog of a `Ui.ConfirmLeave` on the page (its parts carry `data-rask-leave="message"` and `"go"`: the message is written into the first, the dialog is shown modally, and a press on the second makes the navigation that was refused); staying sends nothing and, after Back, puts the address back. Closing the tab, a reload and a link out of the app get the browser's own prompt (`beforeunload`, listened to only while a form is unsaved). The form's `data-rask-saved`, which the server changes after each accepted submit, makes the edits sent before it saved. The form's `data-rask-edited`, which the server writes while a value the reader chose in a control it draws (a listbox, a calendar: no `input` is raised for those) has not been carried by an accepted submit, makes the form unsaved by itself. An `input` or `change` on or inside an element that says `data-rask-unguarded` is not counted — the kit writes it on the fields that hold none of a form's data: a list's search field, a combobox's and a pillbox's input (the text narrows the list; the option picked is the value), a calendar's month and year lists, the field a tab row writes the chosen tab into (a tab is never data the reader would lose, bound or not), a box it measures into — and neither is one the runtime raises by itself. |

### Carousels

| Attribute | On | What the runtime does |
| --- | --- | --- |
| `data-rask-carousel` | the root | On a scroll of its `[data-rask-carousel-track]` and on a resize: `data-at-start` / `data-at-end` on the root and on every `[data-direction]` wrapper, `disabled` on each wrapper's button, `data-selected` on the slide nearest the start and on its button in `[data-rask-carousel-indicators]` (with `aria-current="true"`). A press on a `[data-direction="next" \| "previous"]` button scrolls a slide; on an indicator, to that slide. |
| `data-advance="page"`, `data-wrap="rewind"`, `data-scroll="instant"` | the root | Move by the slides in view; go back to the first from the end; do not animate. |
| `data-autoplay="<ms>"` | the root | Advances on that interval and rewinds at the end; stops under the pointer and while focus is inside the root, starts afresh when both are gone, and never starts under `prefers-reduced-motion`. |
| `data-rask-carousel-controls` + `data-name` | an element outside the root | Its wrappers and indicators drive the root with the same `data-name`. |

### Scrolling

| Attribute | On | What the runtime does |
| --- | --- | --- |
| `data-rask-switch` | a `<button role="switch">` with no handler | A press flips `aria-checked` and `data-checked` in the browser, both held against the morph; nothing is sent. Enter and Space are the button's own; a `disabled` one takes no press. |
| `data-rask-phone="<engine url>"` (+ `data-rask-phone-country`, `-countries`, `-order`) | Ui.Phone's root | Fetches the phone engine (intl-tel-input) from the URL the first time a page carries one, and hands it the field: the tel input formats as it is typed, and ONE bound `<input type="hidden">` inside is kept equal to the number (E.164 once possible) and announced with `input` and `change`; a value the page writes there is shown. The country list (a `[popover]` opened by `popovertarget`) is built, searched, walked and picked from in the browser; nothing is sent. |
| `data-rask-submit-keys="Mod+Enter"` / `="Enter"` | an element around a `<textarea>` or an editable area — a message box | A keydown typed into it that names the key submits the field's form, as its submit button would (validation first, then `submit`), and the key is kept from the field: `Mod+Enter` is Ctrl+Enter or Cmd+Enter, `Enter` is any Enter but Shift+Enter. Outside a form the key is left alone. |
| `data-rask-grow="<lines>"` | a `<textarea>` rendered with `rows` | As the reader types it is made as tall as its text — never shorter than its `rows`, never taller than `<lines>` lines, where it scrolls (`overflow-y` hidden below that, auto there). The height is written in its `style`, held against the morph; nothing is sent. |
| `data-rask-time-list` (+ `data-rask-time-start` on one row) | a time picker's root, around a `[popover]` list of `[role=option]` rows with ids and, for a button trigger, the `role="combobox"` that opens it | The list's cursor is the browser's, as Flux UI's script keeps it: opening lights the start row (`data-active`, named by the combobox's `aria-activedescendant`) and brings it into view by the least movement; the pointer lights the row it is over and never scrolls the list, and that row goes dark when the pointer leaves; closed, ArrowUp / ArrowDown open the list; open, they walk the rows past a `disabled` one and stop at the ends (from no row, the first or the last), Enter presses the lit row, Tab closes, Home / End / letters do nothing. All held against the morph; nothing is sent but the press on a row. |
| `data-rask-scroll-to="<selector>"` | an element holding buttons or links — a pager | A press on a `<button>` or an `<a>` inside it brings the first element the selector matches into view (`"body"` for the top of the document, `"#orders"` for a table). The press is not stopped: the handler or the link runs after it. A press elsewhere in the element, and a value that is no selector, scroll nothing. |

**A render removes only what the server wrote** (`rask-written.ts`): an attribute a script put on a rendered element
itself — a canvas's `width` and `height`, sized by its scoped TypeScript — stays through every diff, morph and
whole-page reply until a render writes that attribute; from then on it is the render's, and a render that leaves it
out removes it. One attribute watch on the document tells the two apart; the runtime's own patch writes are not counted.

**A hook that writes an attribute holds it against the morph** (`rask-owned.ts`): `data-open`, `data-copied`, a tooltip's
`aria-expanded` and a carousel's `disabled` are not what the page rendered, and a re-render neither strips them
nor puts the rendered value back over them.
