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 Metro

The .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

  1. Add packages/ferrule/registry/components/<name>.tsx. Import only from @leoanimesh/ferrule, react and sibling registry files. A test enforces this.
  2. List it in packages/ferrule/registry/registry.json, with sibling imports in dependencies. A test checks that they match.
  3. Add tests in packages/ferrule/test/native and, for anything keyboard or DOM related, packages/ferrule/test/web.
  4. Document every prop in packages/ferrule/docs/components.md. A test fails if one is not mentioned.
  5. Add apps/example/src/demos/<name>.tsx with every state, one line in apps/example/src/catalog.ts, the id in testAllComponentsRender, and a probe in apps/example/src/demos/metrics.tsx. Copy the source over with pnpm --filter ferrule-example sync. A test fails if a registry component has no demo.
  6. Add a docs page: an entry in apps/docs/src/components.ts and a file in apps/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 check and a test fail on hex, rgb() or named colors. Merge useSlot('<name>', '<slot>') before the style prop, 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, never height, on anything holding text. Interactive areas reach tokens.minTouchTarget.
  • Be accessible. Set a role, a name and states. A View with a role also needs accessible to exist for native screen readers.
  • Work in RTL. Use start/end styles. Transforms do not flip, so multiply by rtlSign(useDirection()).
  • Forward its ref, and have no any in 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.log

It 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 and send) and FerruleHostingView (a UIHostingController that measures and reports size).
  • ios/FerruleViews.mm: FERRULE_HOST_VIEW(Name) generates the Fabric component view.
  • android/.../FerruleHostView.kt: FerruleHost and a ComposeView host that measures and reports size.
  • src/host.tsx: NativeHost serialises config, applies the measured size, parses events and picks the fallback.

For a view called Rating:

  1. Spec: copy any src/specs/Ferrule*ViewNativeComponent.ts to FerruleRatingViewNativeComponent.ts and change the name in the last line. Add it to src/nativeSpecs.ts.
  2. package.json: add "FerruleRatingView": "FerruleRatingViewComponentView" to codegenConfig.ios.componentProvider.
  3. iOS: add FERRULE_HOST_VIEW(FerruleRatingView) to ios/FerruleViews.mm, a case to ios/FerruleViews.swift, and ios/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) */ }
    }
  4. Android: add the manager block to FerruleManagers.kt, register it in FerruleNativePackage.kt, and add @Composable fun FerruleRatingContent(host: FerruleHost) to views/FerruleContents.kt.
  5. JS: add the wrapper to src/components.tsx with host('FerruleRatingView', { ...base(rest), config: {...}, on: { change: ... } }).
  6. Harness: wrap the demo in NativeSection in apps/example/src/demos/, and export it as NativeDemo, or add a catalog entry for a native-only view. Then pod install and 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.