UNPKG

@crossplatformai/skills

Version:

Reusable Agent Skills for CrossPlatform.ai projects.

145 lines (119 loc) • 9.7 kB
--- name: web-first-components description: Author shared UI web-first - the default `.tsx` file is plain DOM with Tailwind, and `.native.tsx`, `.ios.tsx`, or `.android.tsx` files are the React Native overrides - with reference implementations to emulate and a characterization-test-first migration workflow. Use when creating a new shared component, forking a component per platform, choosing platform file extensions, or converting a React Native Web component the task already touches. Not for styling or spacing repairs to existing RNW surfaces, resolver and seam enforcement inside an active Post.Build.Ship, or release and deployment policy. --- # Web-First Components ## Use This Skill When Creating a new shared UI component, deciding whether and how to fork a component per platform, choosing platform file extensions, or converting a React Native Web component the current task already touches. Boundaries with neighboring guidance: - Cosmetic styling, spacing, `className`-precedence, and reset repairs to existing RNW surfaces stay with `react-native-web-styling`. A repair never triggers a conversion. - When Post.Build.Ship is active, `post-build-ship/references/react-native-web-to-dom.md` owns seam approval, resolver-context proof, reset ownership, and bounded shipping exceptions. This skill supplies the authoring direction and reference implementations; it adds no enforcement. - Release cadence, deployment, and store policy are out of scope. ## The Direction React Native Web is in maintenance and the ecosystem has converged on web-first authoring: shared code looks like web code, and native is the explicitly selected override. React Strict DOM, Solito 5, Zeego 2, and Expo's RNW-optional direction all express this same rule. It is also the rule this framework already proves at package level with conditional exports, where DOM owns `browser`, `default`, and `main` and native is selected by the explicit `react-native` condition. Platform file extensions express the identical rule at component level: the default is web; native is the explicit selection. ## File Convention - The default file is web: `component.tsx` contains plain React DOM plus Tailwind — real DOM elements, `className`, DOM props. No `react-native` imports in a default file. - The native override is `component.native.tsx`, built from React Native primitives. Use `.ios.tsx` and `.android.tsx` only when the two operating systems genuinely need different implementations (zeego's menus are the worked example). - Never create `.web.tsx` files. Web bundlers do not resolve platform extensions; Metro resolves `.native.tsx`. Web must therefore be the default file for the pair to work everywhere with zero configuration. - Desktop is web. The Electron renderer consumes the web default files. Never fork a file for desktop; desktop behavior differences go through the platform runtime contract (providers), not file extensions. - Keep browser-only primitives (copied shadcn components, Radix wrappers) in an explicitly named internal area such as `components/web/` or `components/browser/`, and keep them out of mobile-facing barrels. - Enforce the boundary with lint rules where available: DOM files cannot import `react-native` or native icon packages; native files cannot import DOM, Radix, or browser-only modules. HTML and raw-text lint exceptions apply only to approved DOM files. ## Interface Discipline - A forked pair exposes one interface. Type the web export against the native implementation — Solito's worked example: `export const Link = NextLink as typeof native.Link` — or type both against a shared `types.ts`. Prop drift between a pair must be a compile error. - No platform conditionals inside forked files: a file is entirely web or entirely native. Runtime platform gates for feature availability stay in providers, not in rendering code. - Every accepted prop is honored, rejected, or explicitly declined by each implementation — the same contract invariant as the shared-contract seam in the Post.Build.Ship reference. ## What Forks And What Does Not - Logic never forks: hooks, state, API calls, validation, and navigation contracts live in plain `.ts` files that both implementations import. Only rendering forks. - Forks are escape hatches, not the norm. A component gets a `.native.tsx` when platforms genuinely diverge, not preemptively. Prefer one shared file until divergence is real. - Screens compose primitives; primitives own the fork. Product and screen code imports primitives from the framework UI packages, never from `react-native` directly, so the substrate remains a leaf-level implementation detail. ## Migration Posture - Strangler fig, never big bang: migrate what the task already touches. Do not convert components the task does not touch, and do not propose bulk rewrites. - New shared components are authored web-first per this convention. - When modifying an existing RNW component within task scope: first extract its logic to a shared hook, then convert the rendering to a web default plus `.native.tsx` override if the task scope allows, following the Characterization-Test-First Migration workflow below. If scope does not allow, ship the smallest RNW-compatible change and leave the boundary unchanged. - Existing RNW surfaces remain governed by `react-native-web-styling` and, under Post.Build.Ship, by the `react-native-web-to-dom` reference until deliberately converted. ## Characterization-Test-First Migration Convert one complete component boundary at a time. The existing behavior already works, so the first test passes before production code moves — characterization-test-first, not red-green TDD. 1. Baseline: inspect worktree ownership, run existing tests, identify every runtime and resolver that consumes the component, and record current props and observable behavior. 2. Native witness first: write a focused test against the current implementation capturing what must survive — props and their visible effects, option order and labels, selected state, exact callbacks and arguments, open, select, and dismiss behavior, and accessibility state. It must pass before anything is renamed; a passing witness describes existing behavior instead of accidentally specifying new behavior. 3. Extract the shared contract into a plain `.ts` module: props interface, domain values and option data, labels, semantic icon keys, and guards. Only behavior both renderers support; no DOM, React Native, browser-global, or platform-detection logic. 4. Rename the current file to `.native.tsx` and point the witness at `./component.native` (extensionless relative import). The witness must stay green through the rename and the contract extraction. 5. Build the DOM default `.tsx`: real HTML elements, DOM accessibility attributes, Tailwind classes, browser-native libraries such as Radix. Consumers keep the unchanged extensionless import — the resolver, not a runtime conditional, selects the implementation. 6. Add the DOM witness: rendered element types, ARIA roles and state, ordered options and checked state, exact callback behavior, every shared prop, and server rendering without browser globals. Place each proof at the lowest layer that can genuinely observe it — load `test-layer-fit` for layer selection. Simulated-DOM tests verify wiring but are not authoritative for focus, keyboard, portal, or dismissal behavior: prove those in real runtimes per surface (Playwright browser, Playwright Electron, Maestro for native mobile on supported targets, and Expo Web separately when it has a distinct resolver or CSS path). A build succeeding is not, by itself, resolver proof — inspect the produced bundle or another observable artifact when implementation selection matters. Under Post.Build.Ship, the `react-native-web-to-dom` reference owns resolver-context enforcement. ## Completion Criteria A conversion is complete only when: the public import and prop contract are unchanged; the native witness still passes; the DOM and SSR witnesses pass; every resolver context selects the intended file; native output excludes browser-only dependencies; web and Electron render real DOM semantics; real-runtime interaction checks pass; styling and layout are stable at representative wide and narrow viewports; and no unrelated component or global styling migration rode along. ## Reference Implementations In order of authority. Point agents and reviewers at these exact paths; the repos' starters and examples do not all follow the pattern. - zeego `packages/zeego/src` — the canonical component pairs: `dropdown-menu.tsx` is pure Radix with zero React Native imports; `.ios.tsx` and `.android.tsx` hold the native menus; the package has no react-native-web dependency. https://github.com/nandorojo/zeego - solito `src/link/` — the typed re-export trick that prevents interface drift: `link.tsx` is `export const Link = NextLink as typeof native.Link` beside `link.native.tsx`. https://github.com/nandorojo/solito - react-strict-dom `apps/example-ui/NativeForkButton` — one shared package consumed by Next.js, Vite, and Expo apps. Ignore its `WebForkButton` and `PlatformButton` neighbors; they deliberately demonstrate the inverse patterns. https://github.com/facebook/react-strict-dom - The rule in prose: https://solito.dev/v5 — "The default file is always web-first." Do not use as references: Solito's `example-monorepos/*` (still render screens through react-native-web), vercel/aix (React Native only, no web target), One (RNW-universal defaults), and create-t3-turbo (no UI sharing).