feat(native): Dialog + Storybook RN - #33
Merged
Merged
Conversation
…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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Demo
Summary
The first non-DOM substrate, marked experimental. Behavior is not re-implemented anywhere: the same
dialogMachine+dialogConnectdrive web and native — only the render and host wiring differ.What's in the branch
@dunky.dev/native-dialog— the RN dialog binding:Portalrenders the host'sModal— presentation only (layering, behind-blocking, a11y containment); every decision flows through the machine.Modal'sonRequestCloseand reports through the corecloseOnBackcontract (onBackNavigationveto → gate → controlled fork).closeOnBackdefaults totrueon 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 staysfalse; opt out withcloseOnBack={false}.pointerEvents="box-none"so empty-area presses fall through to it.Modalnatively stacks, blocks behind, and contains AT focus.transitionendon native);animateddoesn't hang.@dunky.dev/dialogexportsdialogEffects— the substrate-free effect list as plain[fn, propDeps]tuples; substrates compose host effects around it instead of re-implementing the controlled-open echo.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).@testing-library/react-native—packages/native/**is excluded from the root vitest and runs its own jest (folded intopnpm test:ci); tests render the real RN tree and assert the actual native props a device consumes.<primitive>/tests-on-device/*.yaml, e.g.dialog/tests-on-device/) — the host-integration claims a mocked renderer can't reach: realModallayering, box-none touch fall-through, Android hardware Back. Local-only, run before a primitive leaves experimental.role→ RN'sroleprop instead of the legacyaccessibilityRoleenum that crashed Android at mount;hidden→aria-hidden;describedBydropped).dev:expoMetro-only,dev:ios,dev:android, localhost mode withadb reverse; plainondevicestays LAN for physical phones),packages/native/README.mdwith the CLI-only Android SDK/AVD setup (including theavdmanagerhw.keyboard=nopitfall that breaks the emulator's Back button), prebuildandroid//iosgitignored.@dunky.dev/native-dialog(Back default), patches for the dialog packages (runtime bump).Test plan
pnpm test:ci— 137 vitest + 13 native jest testspnpm typecheck/pnpm lint/pnpm format:checkadbon device🤖 Generated with Claude Code