Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
21cc0b8
feat(native): the native substrate — Storybook RN harness + native-di…
ivanbanov Jul 19, 2026
31b1b83
feat(native): on-device Storybook — the same stories under Expo/Metro
ivanbanov Jul 19, 2026
488eb50
fix(native): pin Expo-Go-paired native module versions; persist story…
ivanbanov Jul 19, 2026
1f5ec0e
style: format package.json
ivanbanov Jul 19, 2026
bde6840
fix(native): scope the Expo-generated tsconfig to the app shell
ivanbanov Jul 19, 2026
ae9e51e
chore(native): scope both storybook globs to stories/ dirs
ivanbanov Jul 19, 2026
3778b71
refactor(native): drop the react-native-web storybook; on-device only
ivanbanov Aug 10, 2026
9e4dab4
test(native): migrate to jest-expo + RNTL; drop react-native-web enti…
ivanbanov Aug 10, 2026
7fa38c4
test(native): add Maestro device-E2E scaffold for the host-only layer
ivanbanov Aug 10, 2026
e02c98b
style: format the maestro README
ivanbanov Aug 10, 2026
b29fd95
refactor(native): drop the repo-wide @dunky.dev hoist — the resolver …
ivanbanov Aug 10, 2026
40b0e1f
fix(native): track Expo's paired versions instead of exact pins
ivanbanov Aug 10, 2026
a239941
fix(native): scope the on-device story glob so it can't crawl node_mo…
ivanbanov Aug 10, 2026
001eb76
chore: per-substrate dev/test scripts + on-device setup docs
ivanbanov Aug 12, 2026
b1c9f2a
chore(native): per-platform dev scripts, prebuild artifacts ignored
ivanbanov Aug 12, 2026
b02dc81
chore(deps): update the state-machine runtime packages to 0.3.2
ivanbanov Aug 13, 2026
8c0dde3
feat(native-dialog): hardware Back dismisses by default
ivanbanov Aug 13, 2026
79b993a
chore(native): demo polish, localhost Metro scripts, device setup docs
ivanbanov Aug 13, 2026
907d61a
refactor(native): co-locate Maestro flows with their primitive
ivanbanov Aug 13, 2026
4325f30
refactor(native): rename the device-flow folder to tests-on-device
ivanbanov Aug 13, 2026
be97e2d
feat(native): sync the dialog stories with the react substrate
ivanbanov Aug 13, 2026
87865db
docs: component scenarios are cross-scope — mirror them across substr…
ivanbanov Aug 13, 2026
203b8ad
docs: cover the native substrate commands in CONTRIBUTING
ivanbanov Aug 13, 2026
b64d384
chore: drop the sb.js positional delegator for explicit dev:<substrate>
ivanbanov Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/native-close-on-back-default.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions .changeset/native-substrate.md
Original file line number Diff line number Diff line change
@@ -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'
;<Dialog>
<Dialog.Trigger>
<Text>Open</Text>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Close>
<Text>Close</Text>
</Dialog.Close>
</Dialog.Content>
</Dialog.Viewport>
</Dialog.Portal>
</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.
9 changes: 9 additions & 0 deletions .changeset/state-machine-0-3-2.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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:<platform>` 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)
Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<substrate>`.
- Publishable packages are listed explicitly in `tsdown.config.ts`; a private
package gets a tsconfig path but is never published.

Expand Down
30 changes: 25 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ pnpm install
| Command | What it does |
| ------------------------------ | ---------------------------------------------------------- |
| `pnpm scaffold <name>` | 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` |
Expand All @@ -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/<substrate>` 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:<substrate>`
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:<platform>`) — 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/<primitive>/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,
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
14 changes: 10 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,23 @@
"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' .",
"format": "oxfmt .",
"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",
Expand Down
4 changes: 2 additions & 2 deletions packages/core/dialog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
29 changes: 29 additions & 0 deletions packages/core/dialog/src/effects.ts
Original file line number Diff line number Diff line change
@@ -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'],
],
]
1 change: 1 addition & 0 deletions packages/core/dialog/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
14 changes: 14 additions & 0 deletions packages/native/.rnstorybook/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof view.getStorybookUI> = view.getStorybookUI({
// Persists the selected story across reloads.
storage: {
getItem: AsyncStorage.getItem,
setItem: AsyncStorage.setItem,
},
})

export default StorybookUIRoot
12 changes: 12 additions & 0 deletions packages/native/.rnstorybook/main.ts
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions packages/native/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading