@crossplatformai/skills
Version:
Reusable Agent Skills for CrossPlatform.ai projects.
145 lines (119 loc) • 9.7 kB
Markdown
---
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).