diff --git a/.changeset/native-close-on-back-default.md b/.changeset/native-close-on-back-default.md
new file mode 100644
index 0000000..2ccedb2
--- /dev/null
+++ b/.changeset/native-close-on-back-default.md
@@ -0,0 +1,7 @@
+---
+'@dunky.dev/native-dialog': minor
+---
+
+Hardware Back now dismisses the dialog by default: `closeOnBack` defaults to `true` on the native substrate (the core default stays `false`).
+
+Back is Android's dismiss gesture for transient surfaces — native `Dialog`s are `cancelable` by default — and it plays the same role Escape does on the web, where `closeOnEscape` already defaults to `true`. Requiring an opt-in inverted that platform expectation. Opt out per dialog with `closeOnBack={false}`; the `onBackNavigation` veto is unchanged.
diff --git a/.changeset/native-substrate.md b/.changeset/native-substrate.md
new file mode 100644
index 0000000..042f910
--- /dev/null
+++ b/.changeset/native-substrate.md
@@ -0,0 +1,38 @@
+---
+'@dunky.dev/native-dialog': minor
+'@dunky.dev/dialog': minor
+'@dunky.dev/react-dialog': patch
+---
+
+Add `@dunky.dev/native-dialog` — the React Native binding for the dialog, the
+first package of the native substrate. Same compound API as the React
+binding; the parts translate the core's logical bindings into React Native
+props (`onPress`, `accessibilityState`, `accessibilityViewIsModal`), the
+Portal renders the host's `Modal`, and the hardware Back press reports
+through the core `closeOnBack` contract.
+
+```tsx
+import { Dialog } from '@dunky.dev/native-dialog'
+;
+```
+
+`@dunky.dev/dialog` now exports `dialogEffects` — the substrate-free effect
+list (the controlled-open echo) every binding consumes instead of
+re-implementing, so the controlled contract can't fork between substrates. A
+substrate composes its host-specific effects around it (the React binding
+adds its DOM Escape listener); the echo itself is written once, in core.
diff --git a/.changeset/state-machine-0-3-2.md b/.changeset/state-machine-0-3-2.md
new file mode 100644
index 0000000..acb0536
--- /dev/null
+++ b/.changeset/state-machine-0-3-2.md
@@ -0,0 +1,9 @@
+---
+'@dunky.dev/dialog': patch
+'@dunky.dev/react-dialog': patch
+'@dunky.dev/native-dialog': patch
+---
+
+Update the state-machine runtime packages to 0.3.2.
+
+For `@dunky.dev/native-dialog` this fixes a native crash on Android: the runtime's `normalize` used to emit the machine's `role: 'dialog'` as the legacy `accessibilityRole` prop, which Android rejects at mount (`Invalid accessibility role value: dialog`). It now emits React Native's web-aligned `role` prop, which also restores the intended semantics on iOS (VoiceOver previously got no dialog traits at all). `hidden` now lands on `aria-hidden` instead of being silently ignored.
diff --git a/.gitignore b/.gitignore
index 1fedf58..9af0849 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,14 @@ dist
storybook-static
*.tsbuildinfo
+# generated by the on-device Storybook's metro wrapper (see .rnstorybook/index.ts)
+storybook.requires.ts
+.expo
+
+# generated by expo prebuild (continuous native generation — `expo run:` recreates them)
+packages/native/android
+packages/native/ios
+
# local-only Claude settings (never committed; keep it out of format/lint too)
.claude/settings.local.json
# the Claude Code harness's worktree area (repo worktrees live in .worktrees)
diff --git a/AGENTS.md b/AGENTS.md
index 27190ea..23cc45f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,6 +34,13 @@ editing files in that scope — it overrides anything here for that scope
Some changes are cross-scope. Check what else your change touches before
calling it done.
+Component scenarios (stories, demos) are cross-scope too: when adding one
+to a substrate, evaluate whether the other substrates need the same
+scenario. Keep the sets mirrored — same names, same situations — and when
+one doesn't port because the host lacks the premise (no container portals
+on native, no focus trap on touch), record why next to the story set
+instead of silently skipping it.
+
## Boundaries
These are invariants, not preferences. Violating them breaks the
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 8c30a8f..53cc8bf 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -245,7 +245,7 @@ directory into the templates — the script discovers it. See
(oxlint/oxfmt), and releases (changesets) — one config each, no per-package
tooling.
- Each substrate owns its dev harness and framework deps — e.g.
- `packages/react/.storybook`, run with `pnpm dev [substrate]`.
+ `packages/react/.storybook`, run with `pnpm dev:`.
- Publishable packages are listed explicitly in `tsdown.config.ts`; a private
package gets a tsconfig path but is never published.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3cd8242..4a81acf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -18,7 +18,9 @@ pnpm install
| Command | What it does |
| ------------------------------ | ---------------------------------------------------------- |
| `pnpm scaffold ` | Stamps a new primitive across every substrate |
-| `pnpm test` | Full test suite, watch mode |
+| `pnpm test` | vitest suite (core + dom + react), watch mode |
+| `pnpm test:native` | The native substrate's jest suite (jest-expo + RNTL) |
+| `pnpm test:ci` | Everything once (vitest + native jest) — what CI runs |
| `pnpm typecheck` | `tsc --noEmit` across the whole workspace |
| `pnpm lint` | `oxlint` |
| `pnpm format` / `format:check` | `oxfmt` |
@@ -36,15 +38,33 @@ pnpm test packages/core/dialog/tests/machine.test.ts
Each UI substrate (React, Vue, ...) is a self-contained package under
`packages/` with its own Storybook — the fastest way to see a
-change actually render. `pnpm dev` delegates to the substrate's package via
-`scripts/sb.js`:
+change actually render. Every substrate gets an explicit `dev:`
+script:
```bash
-pnpm dev # @dunky-dev/react Storybook, defaults to http://localhost:6006
-pnpm dev vue # once packages/vue exists
+pnpm dev # alias for dev:react (the default substrate)
+pnpm dev:react # @dunky-dev/react Storybook at http://localhost:6006
pnpm build-storybook # static build of the react substrate's Storybook
```
+### Native (on-device)
+
+The native substrate has no browser Storybook — an Expo dev build renders the
+same stories on a real simulator/emulator through Metro:
+
+```bash
+pnpm dev:expo # Metro only — press `i` / `a` in the Expo CLI to open targets
+pnpm dev:ios # Metro + the app on the iOS simulator
+pnpm dev:android # Metro + the app on the Android emulator
+```
+
+These need a one-time device toolchain (Xcode / Android SDK + AVD) and a
+one-time dev build (`expo run:`) — the full setup, including the
+Android CLI-only path, lives in
+[`packages/native/README.md`](./packages/native/README.md). The same doc
+covers the Maestro device tests (`packages/native//tests-on-device/`),
+which run locally against that dev build.
+
## Filing an issue
A bug report is only as useful as its reproduction. Use an **SSCCE** — Short,
diff --git a/README.md b/README.md
index bdcbfe6..64d7ccf 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,9 @@ adds no behavior of its own — if a decision is needed, it moves into the core
machine so every substrate inherits it. The deep reference is
[ARCHITECTURE.md](./ARCHITECTURE.md).
+The native scripts need a device toolchain (Xcode / Android SDK) and a
+one-time dev build — see [packages/native/README.md](./packages/native/README.md).
+
## License
[MIT](./LICENSE)
diff --git a/package.json b/package.json
index d58a084..c625454 100644
--- a/package.json
+++ b/package.json
@@ -4,8 +4,10 @@
"license": "MIT",
"type": "module",
"scripts": {
- "test": "vitest",
- "test:ci": "vitest run",
+ "test": "pnpm test:react",
+ "test:react": "vitest",
+ "test:native": "pnpm --filter @dunky-dev/native test",
+ "test:ci": "vitest run && pnpm test:native",
"build": "tsdown",
"typecheck": "tsc --noEmit",
"lint": "oxlint --ignore-pattern '.worktrees' .",
@@ -13,8 +15,12 @@
"format:check": "oxfmt --check .",
"knip": "knip",
"scaffold": "node scripts/scaffold.ts",
- "dev": "node scripts/sb.js dev",
- "build-storybook": "node scripts/sb.js build",
+ "dev": "pnpm dev:react",
+ "dev:react": "pnpm --filter @dunky-dev/react dev",
+ "dev:expo": "pnpm --filter @dunky-dev/native dev",
+ "dev:ios": "pnpm --filter @dunky-dev/native ondevice:ios",
+ "dev:android": "pnpm --filter @dunky-dev/native ondevice:android",
+ "build-storybook": "pnpm --filter @dunky-dev/react build",
"changeset": "changeset",
"changeset:version": "changeset version && pnpm format",
"changeset:publish": "pnpm build && changeset publish",
diff --git a/packages/core/dialog/package.json b/packages/core/dialog/package.json
index 357ead8..a915247 100644
--- a/packages/core/dialog/package.json
+++ b/packages/core/dialog/package.json
@@ -35,7 +35,7 @@
},
"dependencies": {
"@dunky.dev/controllable": "workspace:*",
- "@dunky.dev/state-machine": "^0.1.0",
- "@dunky.dev/state-machine-bindings": "^0.1.0"
+ "@dunky.dev/state-machine": "^0.3.2",
+ "@dunky.dev/state-machine-bindings": "^0.3.2"
}
}
diff --git a/packages/core/dialog/src/effects.ts b/packages/core/dialog/src/effects.ts
new file mode 100644
index 0000000..65f3701
--- /dev/null
+++ b/packages/core/dialog/src/effects.ts
@@ -0,0 +1,29 @@
+import type { DialogMachine } from './machine'
+import type { DialogOptions } from './types'
+
+// A substrate effect as plain data: a setup/teardown function plus the prop
+// names that re-run it. Structurally mirrors every adapter's ComponentEffect
+// tuple — core can't import an adapter, and doesn't need to; each substrate's
+// useMachine accepts the tuple as-is and drives it with its own lifecycle.
+export type DialogEffect = [
+ effect: (machine: DialogMachine, props: DialogOptions) => (() => void) | void,
+ deps: (keyof DialogOptions)[],
+]
+
+// Controlled open: the machine never moves on its own when controlled — this
+// echo of the `open` prop is the only thing that transitions it. It carries
+// the prop verbatim: `undefined` hands the state back to the machine
+// (uncontrolled again), a value (re)takes control. The mount echo no-ops.
+//
+// It lives in core because it's part of the controlled contract, not host
+// wiring: every substrate must echo identically or the contract forks. A
+// substrate composes host-specific effects (a DOM Escape listener, a hardware
+// back handler) around this list; it never re-implements the echo.
+export const dialogEffects: DialogEffect[] = [
+ [
+ (machine, props) => {
+ machine.send({ type: 'controlled.sync', value: props.open })
+ },
+ ['open'],
+ ],
+]
diff --git a/packages/core/dialog/src/index.ts b/packages/core/dialog/src/index.ts
index bfac106..b2bc205 100644
--- a/packages/core/dialog/src/index.ts
+++ b/packages/core/dialog/src/index.ts
@@ -1,5 +1,6 @@
export { dialogMachine, type DialogMachine } from './machine'
export { dialogConnect, type DialogApi, type DialogPartBindings } from './connect'
+export { dialogEffects, type DialogEffect } from './effects'
export type {
BackNavigationPayload,
DialogCallbacks,
diff --git a/packages/native/.rnstorybook/index.ts b/packages/native/.rnstorybook/index.ts
new file mode 100644
index 0000000..0363218
--- /dev/null
+++ b/packages/native/.rnstorybook/index.ts
@@ -0,0 +1,14 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+// storybook.requires is generated by the metro withStorybook wrapper (or
+// `sb-rn-get-stories`) from main.ts's stories glob — gitignored, never edited.
+import { view } from './storybook.requires'
+
+const StorybookUIRoot: ReturnType = view.getStorybookUI({
+ // Persists the selected story across reloads.
+ storage: {
+ getItem: AsyncStorage.getItem,
+ setItem: AsyncStorage.setItem,
+ },
+})
+
+export default StorybookUIRoot
diff --git a/packages/native/.rnstorybook/main.ts b/packages/native/.rnstorybook/main.ts
new file mode 100644
index 0000000..e8c6f8b
--- /dev/null
+++ b/packages/native/.rnstorybook/main.ts
@@ -0,0 +1,12 @@
+import type { StorybookConfig } from '@storybook/react-native'
+
+// On-device Storybook: renders the stories on a real simulator/device.
+// Single-level `*/stories/` (not `**`) so the recursive require.context
+// doesn't crawl node_modules and pull in @storybook/react-native's own
+// template example stories (Button/Header/Page).
+const main: StorybookConfig = {
+ stories: ['../*/stories/*.stories.@(ts|tsx)'],
+ addons: [],
+}
+
+export default main
diff --git a/packages/native/AGENTS.md b/packages/native/AGENTS.md
new file mode 100644
index 0000000..99ddfc0
--- /dev/null
+++ b/packages/native/AGENTS.md
@@ -0,0 +1,36 @@
+# Agents / native substrate
+
+React Native bindings. Everything in the root `AGENTS.md` applies; only the
+host differs:
+
+- **No DOM.** The DOM utils (`packages/dom/**`) don't exist here. Host
+ presentation — layering, behind-blocking, accessibility containment,
+ hardware back — comes from React Native's own primitives (`Modal`,
+ `accessibilityViewIsModal`), but every _decision_ still flows through the
+ core machine: the binding wires host mechanics to the machine's
+ events/api and adds no behavior of its own.
+- **One adapter.** A binding imports `@dunky.dev/native-state-machine` only —
+ it re-exports the React lifecycle (`useMachine`; RN renders through React)
+ alongside the native `normalize`/`mergeProps` translation.
+- **Tests run on jest-expo + `@testing-library/react-native`, not vitest.**
+ Real react-native ships untranspiled Flow that vitest can't parse; jest-expo
+ carries the RN + Expo transform allowlist. So `packages/native/**` is
+ excluded from the root vitest and runs its own jest (`pnpm test:native`, and
+ folded into `pnpm test:ci`). One `jest.config.cjs` serves every native
+ primitive; it widens jest-expo's transform allowlist to the `@dunky.dev`
+ scope and wraps its resolver to accept those packages' ESM-only `import`
+ export. Tests render the real RN tree and assert the actual native props a
+ device consumes (`accessibilityViewIsModal`, `pointerEvents`), plus behavior
+ (open/close, controlled, outside-press, hardware back via the Modal's
+ `onRequestClose`). No react-native-web anywhere.
+- **Storybook is on-device only.** An Expo shell renders the stories on a real
+ simulator/device (`pnpm -C packages/native ondevice:ios` / `:android` /
+ `ondevice`) — real `Modal`, real hardware back, real touch, real VoiceOver,
+ Metro resolution. There is no browser storybook: react-native-web fakes the
+ host, so it can't verify what this substrate exists to get right.
+- **Device E2E lives in each primitive's `tests-on-device/` folder.** Maestro flows
+ (e.g. `dialog/tests-on-device/*.yaml`) drive the on-device components for the
+ host-integration claims a mocked renderer can't reach (real Modal, box-none
+ touch fall-through, real hardware Back). Device-run, not in CI — see the
+ device-tests section in `README.md`. Run them before a primitive leaves
+ experimental.
diff --git a/packages/native/README.md b/packages/native/README.md
new file mode 100644
index 0000000..3386438
--- /dev/null
+++ b/packages/native/README.md
@@ -0,0 +1,117 @@
+# @dunky-dev/native
+
+The React Native substrate's dev shell: an Expo app that renders the
+on-device Storybook, plus the jest suite for the native bindings. Private —
+nothing here is published. The rules for editing code in this scope live in
+[AGENTS.md](./AGENTS.md); this file is about getting it running on a
+simulator/emulator.
+
+## Scripts
+
+From the repo root:
+
+| Script | What it does |
+| ------------------ | -------------------------------------------------------------------------------- |
+| `pnpm dev:expo` | Metro only (`expo start`) — pick targets from the Expo CLI (`i` / `a` open both) |
+| `pnpm dev:ios` | Metro + the app on the iOS simulator |
+| `pnpm dev:android` | Metro + the app on the Android emulator |
+| `pnpm test:native` | Runs the jest suite (also folded into `pnpm test:ci`) |
+
+All of these serve Metro on `localhost:8081` (`--localhost`) — right for
+simulators/emulators, unreachable from a physical phone. For a real device
+use the LAN-mode start: `pnpm -C packages/native ondevice`.
+
+## First run: build the dev app
+
+The flows and stories run in a **dev build** (`dev.dunky.ui`, from
+`app.json`), not Expo Go. Once per machine (and after native dependency
+changes), build and install it on the target:
+
+```sh
+pnpm -C packages/native exec expo run:ios # needs Xcode
+pnpm -C packages/native exec expo run:android # needs the Android SDK (below)
+```
+
+After that, the `dev:*` / `ondevice:*` scripts just attach Metro to the
+installed app.
+
+## iOS
+
+Xcode with an iOS simulator is all you need. `pnpm dev:expo` boots the
+simulator, installs nothing by itself (see first run above), and connects
+Metro.
+
+## Android
+
+macOS has no Android toolchain by default — without it, `expo` fails with
+`Failed to resolve the Android SDK path` / `spawn adb ENOENT`. The CLI-only
+setup (no Android Studio, ~3-4 GB):
+
+```sh
+brew install openjdk@17
+brew install --cask android-commandlinetools
+```
+
+Add to `~/.zshrc` — the brew cask installs the SDK outside the default
+`~/Library/Android/sdk` location, so `ANDROID_HOME` is required:
+
+```sh
+export JAVA_HOME="/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"
+export ANDROID_HOME="/opt/homebrew/share/android-commandlinetools"
+export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
+```
+
+Then install the SDK pieces and create an emulator (image is `arm64-v8a` for
+Apple Silicon; use `x86_64` on Intel):
+
+```sh
+yes | sdkmanager --licenses
+sdkmanager "platform-tools" "emulator" "platforms;android-36" \
+ "system-images;android-36;google_apis;arm64-v8a" "build-tools;36.0.0"
+avdmanager create avd -n dunky -k "system-images;android-36;google_apis;arm64-v8a" -d pixel_7
+# avdmanager defaults hw.keyboard=no, which silently breaks the emulator
+# toolbar's Back/Home buttons and host-keyboard input:
+sed -i '' 's/hw.keyboard=no/hw.keyboard=yes/' ~/.android/avd/dunky.avd/config.ini
+```
+
+Boot it with `emulator -avd dunky` (own shell — it stays in the foreground),
+then do the first-run build above. Hardware Back — the path iOS can't
+exercise — is the emulator's toolbar Back button, or
+`adb shell input keyevent KEYCODE_BACK`.
+
+If you'd rather have the GUI tooling, Android Studio
+(`brew install --cask android-studio`) sets up the same SDK in the default
+location — skip `ANDROID_HOME` in that case.
+
+## Testing
+
+- **Unit/behavior**: jest (`pnpm test:native`), runs in CI. Why jest and not
+ vitest, and what these tests cover, is in [AGENTS.md](./AGENTS.md).
+- **Device E2E**: Maestro flows, local-only — see below.
+
+## Device tests (Maestro)
+
+The layer the unit tests can't reach: jest-expo renders the real RN tree but
+mocks the host (the `Modal` mock unwraps, touch is synthetic, no hardware
+Back). Maestro drives the on-device Storybook on a real simulator/emulator,
+where those are real. Scope, on purpose: only the host-integration claims —
+real `Modal` layering, box-none touch fall-through, hardware Back. The
+host-agnostic contract is already covered faster by jest; don't duplicate it.
+
+Each primitive owns its flows in `/tests-on-device/` (e.g.
+[`dialog/tests-on-device/`](./dialog/tests-on-device)); platform-specific flows carry the
+platform in the name (`*.android.yaml`). A flow's header says which story it
+expects on screen — the Storybook shell persists the last selection.
+
+With the [Maestro CLI](https://maestro.mobile.dev) installed
+(`curl -Ls "https://get.maestro.mobile.dev" | bash`), the dev build running
+(above), and the story selected:
+
+```sh
+maestro test packages/native/dialog/tests-on-device/ # all dialog flows
+maestro test packages/native/dialog/tests-on-device/dialog-back.android.yaml # one flow
+```
+
+Not wired into CI — these need a device/emulator. Run them before taking a
+primitive out of experimental, and whenever a host-integration detail
+(Modal, back, touch, a11y containment) changes.
diff --git a/packages/native/app.json b/packages/native/app.json
new file mode 100644
index 0000000..818cf5d
--- /dev/null
+++ b/packages/native/app.json
@@ -0,0 +1,12 @@
+{
+ "expo": {
+ "name": "dunky",
+ "slug": "dunky-ui",
+ "version": "0.0.0",
+ "orientation": "portrait",
+ "userInterfaceStyle": "light",
+ "newArchEnabled": true,
+ "ios": { "supportsTablet": true, "bundleIdentifier": "dev.dunky.ui" },
+ "android": { "package": "dev.dunky.ui" }
+ }
+}
diff --git a/packages/native/babel.config.cjs b/packages/native/babel.config.cjs
new file mode 100644
index 0000000..35aebe6
--- /dev/null
+++ b/packages/native/babel.config.cjs
@@ -0,0 +1,4 @@
+module.exports = function (api) {
+ api.cache(true)
+ return { presets: ['babel-preset-expo'] }
+}
diff --git a/packages/native/dialog/README.md b/packages/native/dialog/README.md
new file mode 100644
index 0000000..850e88a
--- /dev/null
+++ b/packages/native/dialog/README.md
@@ -0,0 +1,46 @@
+# @dunky.dev/native-dialog
+
+React Native binding for [`@dunky.dev/dialog`](../../core/dialog): a compound
+component — `Dialog` plus its parts — that drives the framework-free dialog
+machine. The root owns the machine; parts translate the core's logical
+bindings into React Native props, and wire the host-only concerns (the
+`Modal` layer, the hardware back).
+
+Behavior contract: [`../../core/dialog/SPEC.md`](../../core/dialog/SPEC.md).
+Native-specific surface: [SPEC.md](./SPEC.md).
+
+## Install
+
+```sh
+npm install @dunky.dev/native-dialog
+```
+
+## Usage
+
+```tsx
+import { Text, Button } from 'react-native'
+import { Dialog } from '@dunky.dev/native-dialog'
+
+function ConfirmDelete() {
+ return (
+
+ )
+}
+```
diff --git a/packages/native/dialog/SPEC.md b/packages/native/dialog/SPEC.md
new file mode 100644
index 0000000..c43a55a
--- /dev/null
+++ b/packages/native/dialog/SPEC.md
@@ -0,0 +1,146 @@
+# SPEC / Native / Dialog
+
+The React Native implementation of the [core spec](../../core/dialog/SPEC.md).
+
+## Install
+
+```sh
+npm install @dunky.dev/native-dialog
+```
+
+## Usage
+
+```tsx
+import { Dialog } from '@dunky.dev/native-dialog'
+;
+```
+
+Native-specific notes on top of the core contract:
+
+- **`Portal` renders a React Native `Modal`** — the host's way to layer above
+ the app. The `Modal` supplies presentation only (layering, blocking the
+ view behind, accessibility containment); every decision still flows through
+ the core machine. Nothing is kept mounted while closed.
+- **Hardware Back is the host's back navigation.** The `Modal`'s
+ `onRequestClose` (Android's back button/gesture; Escape under
+ react-native-web) reports through the core `closeOnBack` contract:
+ `onBackNavigation` fires first and can veto, the machine gates on
+ `closeOnBack`, and a controlled dialog only records the intent. On this
+ substrate `closeOnBack` defaults to **true** — Back is the platform's
+ dismiss gesture (native Android dialogs are `cancelable` by default), the
+ same role Escape plays on the web — while the core default stays `false`;
+ the binding seeds the substrate default into the machine config at build
+ time. Opt out with `closeOnBack={false}`.
+- **Outside press** is a press on the Backdrop. The Viewport defaults to
+ `pointerEvents="box-none"`, so a press on the empty area around the window
+ falls through to the Backdrop behind it — same net contract as the web's
+ viewport-press. Presses inside the Content never fall through.
+- **No exit-animation window.** React Native has no `transitionend`; the
+ substrate reports the exit visual as finished immediately, so an `animated`
+ dialog passes through `closing` in the same frame. Entry/exit visuals
+ belong to the consumer (e.g. the `animationType` prop on Portal).
+- **Focus and scroll are the host's.** Touch platforms have no Tab cycle to
+ trap and the `Modal` already blocks interaction and scroll behind itself;
+ the binding adds neither. `accessibilityViewIsModal` carries the modal
+ containment to assistive tech on iOS.
+
+## API
+
+### Dialog
+
+The root. Owns the machine; renders no view of its own. Accepts every core
+option as a prop.
+
+| Prop | Type | Default | Description |
+| ------------------------ | --------------------------- | ---------- | ------------------------------------------------------------------------------------------- |
+| `open` | `boolean` | — | Controlled open state; omit for uncontrolled. |
+| `defaultOpen` | `boolean` | `false` | Uncontrolled initial state. |
+| `onOpenChange` | `(open: boolean) => void` | — | Reports actual open ⇄ close changes. |
+| `role` | `'dialog' \| 'alertdialog'` | `'dialog'` | The dialog flavor (see core spec for alert defaults). |
+| `modal` | `boolean` | `true` | Modality; carried to assistive tech. |
+| `closeOnEscape` | `boolean` | `true` | Kept for cross-substrate parity; no Escape key on touch. |
+| `closeOnInteractOutside` | `boolean` | varies | Whether a Backdrop press dismisses. |
+| `closeOnBack` | `boolean` | `true` | Whether the hardware Back press dismisses. Native default diverges from the core's `false`. |
+| `onInteractOutside` | `(event?) => void` | — | Outside-press report; `preventDefault()` vetoes. |
+| `onBackNavigation` | `(event) => void` | — | Back report; `preventDefault()` vetoes. |
+| `animated` | `boolean` | `false` | Core exit window; completes immediately on native. |
+| `id` | `string` | generated | Base id the part ids derive from. |
+| `children` | `ReactNode` | — | Parts. |
+
+### Dialog.Trigger
+
+A `Pressable` that toggles the dialog.
+
+| Prop | Type | Default | Description |
+| ---------- | ---------------- | ------- | ------------------------- |
+| `...props` | `PressableProps` | — | Merged over the bindings. |
+
+### Dialog.Portal
+
+Renders the React Native `Modal` while the dialog is mounted; `null` when
+closed.
+
+| Prop | Type | Default | Description |
+| --------------- | ----------------------------- | -------- | ---------------------------------------------- |
+| `animationType` | `'none' \| 'slide' \| 'fade'` | `'none'` | The `Modal`'s own entry animation. |
+| `children` | `ReactNode` | — | The layer parts (Backdrop, Viewport, Content). |
+
+### Dialog.Backdrop
+
+A `Pressable` behind the window; pressing it is the outside interaction.
+Only rendered while modal.
+
+| Prop | Type | Default | Description |
+| ---------- | ---------------- | ------- | ------------------------- |
+| `...props` | `PressableProps` | — | Merged over the bindings. |
+
+### Dialog.Viewport
+
+A layout `View` around the window. Defaults to `pointerEvents="box-none"` so
+presses on the empty area reach the Backdrop.
+
+| Prop | Type | Default | Description |
+| ---------- | ----------- | ------- | ------------------------- |
+| `...props` | `ViewProps` | — | Merged over the bindings. |
+
+### Dialog.Content
+
+The dialog window: a `View` carrying the dialog role, labels, and modal
+containment for assistive tech.
+
+| Prop | Type | Default | Description |
+| ---------- | ----------- | ------- | ------------------------- |
+| `...props` | `ViewProps` | — | Merged over the bindings. |
+
+### Dialog.Title / Dialog.Description
+
+`Text` parts that name and describe the window; their presence drives the
+content's labels per the core contract.
+
+| Prop | Type | Default | Description |
+| ---------- | ----------- | ------- | ------------------------- |
+| `...props` | `TextProps` | — | Merged over the bindings. |
+
+### Dialog.Close
+
+A `Pressable` that dismisses from inside.
+
+| Prop | Type | Default | Description |
+| ---------- | ---------------- | ------- | ------------------------- |
+| `...props` | `PressableProps` | — | Merged over the bindings. |
diff --git a/packages/native/dialog/package.json b/packages/native/dialog/package.json
new file mode 100644
index 0000000..6b5e3ad
--- /dev/null
+++ b/packages/native/dialog/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@dunky.dev/native-dialog",
+ "version": "0.0.0",
+ "description": "React Native binding for @dunky.dev/dialog.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/dunky-dev/ui.git",
+ "directory": "packages/native/dialog"
+ },
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "sideEffects": false,
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "publishConfig": {
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsdown"
+ },
+ "dependencies": {
+ "@dunky.dev/dialog": "workspace:*",
+ "@dunky.dev/native-state-machine": "^0.3.2"
+ },
+ "devDependencies": {
+ "@testing-library/react-native": "^13.3.3",
+ "@types/react": "^19.2.15",
+ "react": "19.2.3",
+ "react-native": "^0.86.0",
+ "react-test-renderer": "19.2.3"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+}
diff --git a/packages/native/dialog/src/context.ts b/packages/native/dialog/src/context.ts
new file mode 100644
index 0000000..96abb05
--- /dev/null
+++ b/packages/native/dialog/src/context.ts
@@ -0,0 +1,19 @@
+import { createContext, useContext, type Context } from 'react'
+import type { DialogApi, DialogMachine } from '@dunky.dev/dialog'
+
+export interface DialogContextValue {
+ api: DialogApi
+ machine: DialogMachine
+}
+
+export const DialogContext: Context = createContext<
+ DialogContextValue | undefined
+>(undefined)
+
+export const useDialogContext = (): DialogContextValue => {
+ const context = useContext(DialogContext)
+ if (context === undefined) {
+ throw new Error('Dialog parts must be rendered within a