Hotwire native bridge
Skill davidteren/hotwire-codex-skills/skills/hotwire-native-bridge
Unofficial Claude Code skillset inspired by The Rails and Hotwire Codex — 8 skills for building interactive Rails + Hotwire apps (Turbo, Stimulus, Hotwire Native) across web, iOS & Android, each with a runnable checker.
npx -y skills add davidteren/hotwire-codex-skills --skill hotwire-native-bridgeAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 3 stars3 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Create and validate Strada / Hotwire Native bridge components across web (Stimulus), iOS (Swift), and Android (Kotlin). Use when adding native UI driven by the web — a native menu, share button, toolbar, native form submit — to a Hotwire Native app, or when a bridge component "works on web but not in the app", a native control is missing/duplicated, or a value sent from the web never arrives natively. Generates the three platform halves from one component name and lints the cross-platform contract for name mismatches and silently-dropped payload fields.
SKILL.md
5.1 KB, as published. Nobody here has run it
Hotwire Native bridge components (Strada)
A Strada bridge component has three halves that must agree: a web Stimulus
controller (BridgeComponent), an iOS BridgeComponent (Swift), and an Android
BridgeComponent (Kotlin). They are bound by an implicit contract:
- A component name string (e.g.
"nav-menu") — identical on all three sides. - Message events —
connect/disconnectplus any custom events. - A JSON payload shape — every key the web sends must be decoded on both native sides, or it is silently dropped (no error).
Getting any of these subtly wrong is the #1 time-sink: a name typo makes the component invisible; a payload key missing from one native struct vanishes with no crash. This skill generates the three halves consistently and lints the contract.
See references/bridge-contract.md for the full contract + the real Piazza
nav-menu example, and the gotcha catalogue.
When to use
- Adding a native component backed by web markup (menu, toolbar, share sheet, native submit button, native picker).
- Debugging "works on web, broken/missing in the native app", a duplicated native control after navigation, or a value that doesn't reach the native side.
- Reviewing a PR that touches any
*Component.swift/*Component.kt/controllers/bridge/*.js— run the linter.
Generate a new component
scripts/new_bridge_component.sh <name-in-kebab> \
--web path/to/piazza-web \
--ios path/to/piazza-ios \
--android path/to/piazza-android \
--package com.yourco.app
Writes (refuses to overwrite):
web/app/javascript/controllers/bridge/<snake>_controller.jsios/<App>/Bridge/<Pascal>Component.swiftandroid/.../<Pascal>Component.kt
Then register it (the generator prints these — it does NOT edit registries). Hotwire Native 1.x (default — templates target this):
- iOS:
Hotwire.registerBridgeComponents([<Pascal>Component.self])inAppDelegate, before anyNavigatoris created (make the navigator lazy, else the component never attaches — hotwire-native-ios #35).import HotwireNative. - Android:
Hotwire.registerBridgeComponents(BridgeComponentFactory("<name>", ::<Pascal>Component))in yourApplicationsubclass (also setHotwire.config.jsonConverter = KotlinXJsonConverter()). Imports fromdev.hotwire.core.bridge; destination typeHotwireDestination. - Web: install
@hotwired/hotwire-native-bridge; Stimulus auto-registers the controller asbridge--<name>if it lives underapp/javascript/controllers/bridge/.
Strada-beta apps (the Piazza example repos still use this): iOS adds
<Pascal>Component.selfto aBridgeComponent.allTypesextension; Android adds the factory to abridgeComponentFactorieslist passed to the WebFragment; web imports@hotwired/strada. Same contract, older wiring. Full mapping:references/bridge-contract.md.
- Markup:
data-controller="bridge--<name>", item targetsdata-bridge--<name>-target="item", payload attrs viadata-bridge-<attr>(read in JS withbridgeElement.bridgeAttribute("<attr>")).
Fill in the TODOs in each native half (render UI from data, reply on selection).
Lint the contract
scripts/lint_bridge_contract.sh --root <dir-with-the-three-repos>
# or: --web W --ios I --android A
Checks, and exits non-zero on drift:
- Name parity — every component name present on web, iOS, and Android (catches typos / unregistered = invisible components).
- iOS registration — a component declared but absent from
allTypes. - Payload field parity — a field decoded on one native side but not the other
(the silent-drop class — e.g. Piazza's
icon, sent by web + decoded on iOS, has no field on Android).
Heuristic text scan (grep/awk), not a parser — conservative, good enough to gate a PR. It is the check that pays for itself: payload drift produces no runtime error.
The rules that matter (least astonishment)
- The component
nameis an API across three repos — renaming means three edits. - Add a payload key → add it to the Swift
Decodableand the Kotlin@Serializablein the same change, or it silently disappears on the side you forgot. disconnect()must undo whatconnect()rendered, or native controls duplicate on the next Turbo navigation.- Components degrade to plain web when no native bridge is present — keep the web markup usable on its own (the JS hides it only when the bridge connects).