Skip to content

feat(native): Dialog + Storybook RN - #33

Merged
ivanbanov merged 24 commits into
mainfrom
feat/native-substrate
Aug 13, 2026
Merged

feat(native): Dialog + Storybook RN#33
ivanbanov merged 24 commits into
mainfrom
feat/native-substrate

Conversation

@ivanbanov

@ivanbanov ivanbanov commented Jul 19, 2026

Copy link
Copy Markdown
Member

Demo

Summary

The first non-DOM substrate, marked experimental. Behavior is not re-implemented anywhere: the same dialogMachine + dialogConnect drive web and native — only the render and host wiring differ.

What's in the branch

  • @dunky.dev/native-dialog — the RN dialog binding:
    • Portal renders the host's Modal — presentation only (layering, behind-blocking, a11y containment); every decision flows through the machine.
    • Hardware Back arrives via the Modal's onRequestClose and reports through the core closeOnBack contract (onBackNavigation veto → gate → controlled fork).
    • closeOnBack defaults to true on this substrate — Back is Android's dismiss gesture, the role Escape plays on web; the binding seeds the default into the machine config, the core default stays false; opt out with closeOnBack={false}.
    • Outside press = Backdrop press; the Viewport defaults to pointerEvents="box-none" so empty-area presses fall through to it.
    • No layer stack / focus trap / scroll lock on this host: RN's Modal natively stacks, blocks behind, and contains AT focus.
    • The exit window completes immediately (no transitionend on native); animated doesn't hang.
  • @dunky.dev/dialog exports dialogEffects — the substrate-free effect list as plain [fn, propDeps] tuples; substrates compose host effects around it instead of re-implementing the controlled-open echo.
  • On-device Storybook only — an Expo dev-build shell (dev.dunky.ui) renders the stories on a real simulator/device via Metro; the react-native-web browser harness was dropped (it fakes the host this substrate exists to get right).
  • Tests on jest-expo + @testing-library/react-nativepackages/native/** is excluded from the root vitest and runs its own jest (folded into pnpm test:ci); tests render the real RN tree and assert the actual native props a device consumes.
  • Maestro device E2E co-located per primitive (<primitive>/tests-on-device/*.yaml, e.g. dialog/tests-on-device/) — the host-integration claims a mocked renderer can't reach: real Modal layering, box-none touch fall-through, Android hardware Back. Local-only, run before a primitive leaves experimental.
  • State-machine runtime bumped to 0.3.2 — picks up the native a11y translation fixes (role → RN's role prop instead of the legacy accessibilityRole enum that crashed Android at mount; hiddenaria-hidden; describedBy dropped).
  • DX/docs — per-platform dev scripts (dev:expo Metro-only, dev:ios, dev:android, localhost mode with adb reverse; plain ondevice stays LAN for physical phones), packages/native/README.md with the CLI-only Android SDK/AVD setup (including the avdmanager hw.keyboard=no pitfall that breaks the emulator's Back button), prebuild android//ios gitignored.
  • Changesets: minor for @dunky.dev/native-dialog (Back default), patches for the dialog packages (runtime bump).

Test plan

  • pnpm test:ci — 137 vitest + 13 native jest tests
  • pnpm typecheck / pnpm lint / pnpm format:check
  • Maestro flows pass on an Android 36 emulator (dialog open/close/outside-press + hardware-back)
  • Back-closes verified with the real emulator toolbar Back and adb on device

🤖 Generated with Claude Code

ivanbanov and others added 6 commits July 20, 2026 00:07
…alog

The first non-DOM substrate, patterned on the state-machine sandbox's native
app (React lifecycle from the react adapter — RN renders through React —
plus the native normalize/mergeProps translation):

- @dunky-dev/native — private substrate infra: Storybook via
  @storybook/react-native-web-vite (browser harness over react-native-web;
  `pnpm dev native`), plus an AGENTS.md for the substrate's rules.
- @dunky.dev/native-dialog — the RN dialog binding. Portal renders the
  host's Modal (presentation only); the hardware back (onRequestClose)
  reports through the core closeOnBack contract; a Backdrop press is the
  outside interaction, reaching it through the Viewport's box-none
  fall-through. No layer stack / focus trap / scroll lock: the Modal owns
  presentation on this host, decisions stay in the machine.
- @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 per substrate.
  The react binding composes [...dialogEffects, trackEscape]; native uses
  the list as-is.
- Tests render through react-native-web in jsdom (vitest aliases
  react-native), so the back path is exercised via RNW's Modal mapping
  Escape to onRequestClose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The react-native-web harness iterates fast but approximates the host; this
adds the host-truth runner: an Expo shell (SDK 57, paired with the harness's
RN 0.86) running @storybook/react-native v10 over the same story files — one
story source, two runners.

- metro.config.cjs: workspace watch-folders + single-React dedupe (the
  sandbox's resolveRequest redirect), wrapped in withStorybook, which
  generates .rnstorybook/storybook.requires.ts from the stories glob at
  bundle time (generated file gitignored).
- The Expo entry and .rnstorybook glue are excluded from the root
  typecheck — Metro compiles them, and the generated module doesn't exist
  in CI.
- Smoke-tested headlessly: `expo export --platform ios` bundles 1791
  modules through Metro, validating source-package resolution and story
  compat without a simulator.

Run: pnpm -C packages/native ondevice:ios (or :android / ondevice).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… selection

Expo Go ships fixed native builds per SDK — a JS-side version that floats
past them segfaults at startup (react-native-worklets 0.10.2 JS against
Go's 0.10.0 native crashed in worklets::jsi_utils::addMethod). Pin the
native-module-paired deps exactly (react 19.2.3, react-native-reanimated
4.5.0, react-native-worklets 0.10.0) so ranges can't drift from the SDK
pairing; `npx expo install --check` is the verifier.

Also wire AsyncStorage (SDK-paired 2.2.0) into getStorybookUI so the
on-device runner keeps the selected story across reloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An unscoped nearest-tsconfig would shadow the root config (and its
workspace paths) for everything under packages/native in editors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One pattern for both runners, and the on-device glob no longer hardcodes
the dialog package — the next primitive's stories are picked up as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ivanbanov ivanbanov changed the title feat(native): the native substrate — Storybook RN harness + native-dialog feat(native): Dialog + Storybook RN Jul 20, 2026
ivanbanov and others added 18 commits August 10, 2026 12:58
The browser storybook rendered RN through react-native-web — a fake host
(Modal, hardware back, touch, VoiceOver all approximated), so it couldn't
verify what this substrate exists to get right, and it carried its own
duplicate-React failure mode. The on-device Expo runner is the single
storybook now.

Kept: react-native-web strictly as the headless test render target — the
unit tests (via the vitest react-native alias + @testing-library/react in
jsdom) still cover the host-agnostic binding logic, and its render deps live
in native/dialog's own devDependencies, untouched. 149 tests still green.

- Remove packages/native/.storybook and the web-storybook deps/scripts from
  the infra package (@storybook/react-native-web-vite, react-dom,
  react-native-web, vite, the dev/build scripts).
- The dialog story is now an on-device story: types from
  @storybook/react-native.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rely

react-native-web is gone from the repo. The native tests now render the real
react-native tree through jest-expo + @testing-library/react-native, so they
assert the actual native props a device consumes (accessibilityViewIsModal,
pointerEvents box-none, accessibilityLabelledBy) instead of a web
translation — the blind spot RNW structurally couldn't cover — plus behavior
(open/close, outside-press + veto, onOpenChange, controlled, hardware back
via the Modal's onRequestClose, exit window).

Toolchain notes (why it's shaped this way):
- packages/native runs its own jest-expo, excluded from the root vitest; real
  RN ships untranspiled Flow vitest can't parse. `pnpm test:ci` runs both.
- jest.config widens jest-expo's transform allowlist to @dunky.dev (pnpm
  nests a second node_modules the lookahead would re-ignore) and wraps its
  resolver to accept those packages' ESM-only `import` export.
- RNTL 13 (sync render, react-test-renderer 19.2.3) — RNTL 14's async
  concurrent renderer flapped the act environment across tests here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maestro flows for what even the real-RN-tree unit tests can't reach — a real
Modal over the app, the box-none Viewport fall-through under real touch, and
Android's hardware Back button firing closeOnBack. Two flows (cross-platform
open/close/outside-press; Android-only back) targeting a dev build, with a
README covering prerequisites and the story each expects.

Device-run, not wired into CI — the on-device complement to the jest-expo unit
suite. Run before a native primitive leaves experimental.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…suffices

The publicHoistPattern entry was a red herring added while chasing jest
resolution: the real fix was jest-resolver.cjs accepting the @dunky.dev
packages' ESM-only `import` condition. With that in place jest-resolve
follows pnpm's nested symlinks fine, so hoisting the whole scope into the
root node_modules bought nothing. Removed — native jest stays 13/13, and the
workspace layout is back to storybook-only hoisting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Exact-pinning the native modules (worklets 0.10.0, reanimated 4.5.0) froze
them at one SDK 57 patch while Expo Go moved on — the JS/native worklets
mismatch segfaults Expo Go at launch. Let `expo install --fix` own these:
expo ~57.0.11, react-native 0.86.2, react-native-reanimated 4.5.1,
react-native-worklets 0.10.1. `npx expo install --check` is the referee; the
on-device runner launches clean again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dules

The recursive require.context from `**/stories/*.stories` walked into
packages/native/node_modules and pulled @storybook/react-native's own
template stories (Button/Header/Page) into an "EXAMPLE" section. Single-level
`*/stories/*.stories` matches the primitives (dialog/stories/...) without
descending into node_modules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`pnpm dev`/`pnpm test` keep defaulting to the react/vitest side, with
explicit `dev:react|native|expo` and `test:react|native` aliases. New
packages/native/README.md documents the device toolchain (Xcode, the
brew CLI-only Android SDK path) and the one-time dev build; the Maestro
and root READMEs point at it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`dev:expo` is Metro only; `dev:ios` / `dev:android` open one platform.
`expo run:android` (prebuild) moved expo/react/react-native to real
dependencies and regenerates packages/native/android|ios, now gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
For the native binding this picks up the a11y translation fixes: role
now lands on RN's web-aligned role prop (the legacy accessibilityRole
enum crashed Android at mount on 'dialog'), hidden on aria-hidden, and
describedBy is dropped instead of clobbering the label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
closeOnBack now defaults to true on this substrate — Back is Android's
dismiss gesture (native Dialogs are cancelable by default), the role
Escape plays on the web where closeOnEscape already defaults to true.
The binding seeds the default into the machine config at build time;
the core default stays false. Opt out with closeOnBack={false}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Close On Back story centers a bigger trigger with a readable hint
label; the Maestro flow follows the new trigger text. Dev scripts serve
Metro on localhost (simulator/emulator path; plain `ondevice` stays LAN
for physical phones), and the README covers the CLI-only Android SDK
setup including the avdmanager hw.keyboard=no pitfall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device flows move from the central .maestro/ into each primitive's
device/ folder (dialog/device/*.yaml), next to src/tests/stories, so a
primitive carries its whole test surface. Platform-specific flows keep
the platform in the name (*.android.yaml). The how-to lives in the
package README's device-tests section now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same names, same scenarios — Standard, AlertDialog, LongContent,
Trigger, Controlled, Nested, CloseOnBack — minus the two whose premise
doesn't exist on this host: scoped (no container portals; a Modal owns
the screen) and loginForm (its point is the web focus trap). The react
side gains the missing controlled story. A decorator centers every
native story. The dialog flow retargets the Trigger story.

The corner close button needs zIndex on RN: later siblings take the
top of the touch order (unlike CSS), so the full-width Title swallowed
the press — caught by the device flow, invisible to jest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ates

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`pnpm dev <substrate>` predates the per-substrate scripts; now that every
substrate has an explicit `dev:<substrate>`, the root scripts filter
directly and the delegator goes away. `dev` stays as the react alias;
`build-storybook` filters to the react package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ivanbanov
ivanbanov merged commit 9f2ea09 into main Aug 13, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant