Contributing
Contributing for Ferrule, a React Native UI library for iOS, Android and web. Set up the repo, add a component, add a native view.
pnpm install
pnpm build # compile packages/ferrule to lib/, which the harness imports
pnpm test # CLI, component, catalog and pack tests
pnpm typecheck
pnpm check # the harness imports no primitives from react-native
pnpm check:pack # what each package would ship
pnpm example # run the harness (apps/example) in MetroThe .claude/skills folder has agent skills for this work, which also read fine as checklists: ferrule-ui for building with the library, ferrule-build-component for adding a component, and ferrule-test-example for running it through the harness.
Adding or changing a component
- Add
packages/ferrule/registry/components/<name>.tsx. Import only from@leoanimesh/ferrule,reactand sibling registry files. A test enforces this. - List it in
packages/ferrule/registry/registry.json, with sibling imports independencies. A test checks that they match. - Add tests in
packages/ferrule/test/nativeand, for anything keyboard or DOM related,packages/ferrule/test/web. - Document every prop in
packages/ferrule/docs/components.md. A test fails if one is not mentioned. - Add
apps/example/src/demos/<name>.tsxwith every state, one line inapps/example/src/catalog.ts, the id intestAllComponentsRender, and a probe inapps/example/src/demos/metrics.tsx. Copy the source over withpnpm --filter ferrule-example sync. A test fails if a registry component has no demo. - Add a docs page: an entry in
apps/docs/src/components.tsand a file inapps/docs/examples/. A test fails if it is missing.
Every component must:
- Use semantic tokens, never raw colors, so a palette can reprint it.
ferrule checkand a test fail on hex,rgb()or named colors. MergeuseSlot('<name>', '<slot>')before thestyleprop, so themes can override it. - Stay JS-only apart from Reanimated. No other native dependency without a strong case. Draw controls in Views rather than using a platform widget whose look varies, and do all animation with Reanimated through
@leoanimesh/ferrule's exports. - Grow with text. Use
minHeight, neverheight, on anything holding text. Interactive areas reachtokens.minTouchTarget. - Be accessible. Set a role, a name and states. A View with a role also needs
accessibleto exist for native screen readers. - Work in RTL. Use
start/endstyles. Transforms do not flip, so multiply byrtlSign(useDirection()). - Forward its ref, and have no
anyin public props.
Keep the docs in sync
Any change to a prop, component, CLI command or flag, theme API or behaviour updates its docs in the same pull request. AGENTS.md lists which file covers what. pnpm --filter ferrule-docs test fails if a component has no section in components.md, a public prop or CLI flag is not mentioned, or a native view is missing from the coverage table. It cannot tell whether what the docs say is still true, so check that yourself.
Checking consistency
Open the harness at ?component=metrics on each platform and save the logs. Each has a FERRULE_METRICS line. Then:
node scripts/compare-metrics.mjs ios.log android.log web.logIt fails if any component differs by more than 1dp.
Adding a palette
Add it to palettes in packages/ferrule/src/theme.ts and to palettes in registry/registry.json. A test keeps those two lists matched and audits every palette against AA.
Native views
Native code lives only in packages/ferrule-native, one view per component on a shared SwiftUI and Compose host. See adding a component. No third-party native UI wrappers: write the Swift and Kotlin here. Core @leoanimesh/ferrule and copied components must never import @leoanimesh/ferrule-native.
Releases
Do not bump a version in a change unless you mean to release: on main, a new version publishes to npm. The release checklist has the steps.
Pull requests
Keep them small, and say which platforms you ran. Commit messages use Conventional Commits (feat(button): ..., fix(sheet): ...). Do not claim a platform you did not run.
Ferrule follows a code of conduct, and security issues go through SECURITY.md. By contributing you agree your work is licensed under the MIT license.
Adding a component
Every view has the same shape, so a new one is mechanical. Fabric is reached through codegen, and SwiftUI or Compose through one shared host on each platform:
ios/FerruleHost.swift:FerruleHost(the config andsend) andFerruleHostingView(aUIHostingControllerthat measures and reports size).ios/FerruleViews.mm:FERRULE_HOST_VIEW(Name)generates the Fabric component view.android/.../FerruleHostView.kt:FerruleHostand aComposeViewhost that measures and reports size.src/host.tsx:NativeHostserialises config, applies the measured size, parses events and picks the fallback.
For a view called Rating:
- Spec: copy any
src/specs/Ferrule*ViewNativeComponent.tstoFerruleRatingViewNativeComponent.tsand change the name in the last line. Add it tosrc/nativeSpecs.ts. package.json: add"FerruleRatingView": "FerruleRatingViewComponentView"tocodegenConfig.ios.componentProvider.- iOS: add
FERRULE_HOST_VIEW(FerruleRatingView)toios/FerruleViews.mm, acasetoios/FerruleViews.swift, andios/views/FerruleRatingContent.swift:@available(iOS 17.0, *) struct FerruleRatingContent: View { @ObservedObject var host: FerruleHost var body: some View { /* read host.double("value"); write through host.binding("value", default: 0.0) */ } } - Android: add the manager block to
FerruleManagers.kt, register it inFerruleNativePackage.kt, and add@Composable fun FerruleRatingContent(host: FerruleHost)toviews/FerruleContents.kt. - JS: add the wrapper to
src/components.tsxwithhost('FerruleRatingView', { ...base(rest), config: {...}, on: { change: ... } }). - Harness: wrap the demo in
NativeSectioninapps/example/src/demos/, and export it asNativeDemo, or add a catalog entry for a native-only view. Thenpod installand rebuild both platforms.
Rules: events carry one JSON value. A view with no native counterpart on one platform gets excludedPlatforms in its spec, and platforms in its wrapper. A control's value changes optimistically on the native side (host.binding, host.change) and is corrected by the next config. Anything that presents itself takes headless: true.
Views that hold React children cannot go through the SwiftUI host, because Fabric must mount the children. FerruleGlassViewComponentView.mm shows that form.
Sizing
NativeHost waits for the native side to report the size it wants, then gives that to Yoga through style, and shows the view after that. The first frame is hidden, so nothing visibly jumps, but the size arrives one JS round trip late.
shortcut: sized by event rather than a C++ shadow node, so the height lags by one frame and is unknown in the first layout pass. Write a custom shadow node if a screen needs synchronous layout.