Native capability for the Rails target
Status: Normal Compilation independently emits selected iPhone and Android projects under ios/ and
android/. Each receives public-only, Account-free navigation. Missing native authentication, session restoration,
protected navigation, push, and authored launcher icons remain precise reviewed gaps. The private iPhone Account
qualifier is separate from this public behavior. Tests and CI hold the implemented claims, and the
evidence index records the Revyl, Simulator, and hosted observations.
Hotwire Native is the first Rails-specific approach because it renders the server's HTML inside ordinary iOS and Android applications, uses platform navigation, and permits selective Swift/Kotlin enhancement. See the official Hotwire Native overview and architecture explanation.
This Capability design is a current implementation direction, not the definition of owned native output. A controlled comparison may later favor another client architecture.
Why this is a Capability
The compilation target remains Rails. Native changes how that Rails application is presented, built, notified, and delivered; it does not replace the backend architecture.
The Plan requests each client independently by including an enabled-object fact at native.ios or
native.android; an omitted platform requests no client. Native is therefore neither subject-triggered nor
unconditional. Each fact can later gain bounded platform-specific configuration such as a bundle identifier
without changing the selection shape.
The Capability catalog (repository-only) lists ten subject triggers and none of them can imply a native
client, so waiting for a trigger would mean never emitting one.
Decision 0016 (repository-only) records why the switch is per
platform: a Mac is required to build the iOS project but not the Android one, and the two distribution programs
are separate memberships obtained by different people.
The complete Capability direction would emit:
- Rails-side native detection, authentication, navigation, and push integration;
- a conventional Xcode project;
- a conventional Android/Gradle project;
- Bridge components or fully native screens required by Plan facts;
- build and test workflows;
- root commands and diagnostics; and
- native-specific repository guidance and Compilation Record coverage.
The output may span several directories. That does not make native universal Core or a peer target profile.
Omitting a platform requests none of its project, build and test workflows, path configuration, or Compilation Record coverage. It does not change Web main navigation (repository-only), which derives independently from admitted public and protected Scaffold indexes plus one Account entry. Ordinary iOS and Android clients receive separate Account-free, public-only projections; only the private native qualifier may add Account self. Account meaning never implies an Apple client or a tab bar.
Each platform lowers independently. A requested client without a usable public destination or platform identity is omitted and listed in the reviewed GapSet while a coherent Rails application can still compile. Requesting both platforms does not require both to be lowerable.
Native theme policy
Omitting application.appearance.theme selects fixed light. Explicit light and dark select that appearance in
the native shell and embedded Rails pages. auto follows system appearance. Both native color palettes remain
available for these modes.
The authored toggle choice provides the browser's Light / Dark / System selector and browser-local preference.
Selected native clients are still emitted, but use automatic appearance. Their embedded Rails responses also use
automatic appearance and omit the browser selector and preference storage. A browser preference cannot by itself
control platform window appearance; no native preference control or bridge is generated.
Analysis records this residual once at /application/appearance/theme, with code
foundation_plan.gap.appearance.native_theme_preference.not_generated and status partially_generated. The record
names only the emitted iOS and Android clients. A browser-only Plan or an omitted native client contributes no such
gap. Compilation carries the same reviewed GapSet and preserves the authored toggle in the retained Plan.
The native mappings are covered by emitted-source checks; native runtime switching is not qualified by browser
theme tests.
Android application lowering and runtime
AndroidApplication consumes the same target-neutral public navigation model as iOS on the ordinary path, derived
independently
from the exact captured input. It does not reuse the private iPhone Account projection. The versioned
foundation-plan-rails/android-application-2026-09 lowering maps all 28 semantic icons to pinned Material assets.
A domain supplies the HTTPS Rails origin and reversed application-ID prefix. Hyphens become _h; a numeric-leading
label receives d_. DNS labels contain no underscores, so these transforms preserve distinct identities.
The application key remains the final Java package component. Android IDs are capped at the platform's 223-byte
package-name limit. Without a domain, the identity is visibly provisional: invalid.firstdraft.<application_key>
and https://<application-key-with-hyphens>.invalid. The generated definition records that placeholder choice.
AndroidApplicationTaskPlan retains one digest-verified Core archive and exactly eight replacement seams:
application properties, generated Kotlin definition and tests, the activity layout, strings, light and dark colors,
and bundled path configuration. AndroidRenderingContext binds the exact captured input, its Android and Appearance
projections, admitted public mutations, and the unchanged submitted Plan digest. The composer rechecks seams, owners,
modes, and provenance before mounting android/. Unselected or declined Android never loads its Core archive.
The APK's manifest and BuildConfig carry the Plan digest, and generated tests compare it with the Kotlin definition.
Application IDs are separate from the stable Kotlin namespace so an app-specific identifier needs no source rewrite.
Core uses Hotwire Native Android 1.3.1, Material DayNight views, JDK 17, Kotlin 2.3.0, AGP 8.13.2, and the checksummed Gradle 9.2.0 wrapper. The initial build targets Android 16/API 36 with minimum API 28. That minimum is a build configuration, not evidence that every supported OS/device has been exercised. Native libraries, Google services, Firebase, signing credentials, and Play publication are unnecessary for the generated debug preview.
One destination uses a single Navigator without a tab bar. Two through five use Hotwire's tab controller and separate
back stacks. More than five use four tabs plus a native More list containing every remaining destination. Overflow
routes push from More so Back returns to that list; only direct roots replace their tab's root. Exact admitted public
new/edit routes use full-screen Hotwire Web destinations with context: modal and pull-to-refresh disabled,
matching the pinned Android demo. Rails owns HTML, forms, validation, and server redirects;
Hotwire owns visits, cookies, and history. The Compiler supplies bundled rules rather than requiring an ungenerated
remote path-configuration endpoint.
Tab labels remain visible, wrap, and follow system font size. Authored Entity icons supply meaningful tab symbols;
an absent icon retains the semantic grid fallback. Core handles phone rotation in the existing activity, following
the pinned Hotwire demo. The optional bottom-sheet destination remains registered, but generated forms use the
full-screen modal because the nested sheet failed to scroll after validation increased its content height.
Unsaved draft restoration after process death or other activity recreation is separate. Failed regular and modal
page loads expose a Try again button
through Hotwire's public refresh method; no write is queued or automatically retried.
Path configuration selects destinations and modal
presentation; it does not remove HTML inside the WebView. Rails Core uses hotwire_native_app? to omit the web
header and persisted browser theme for native requests, including new/edit forms. This applies with or without
authored Appearance. Native HTML supplies a concise page title, falling back to the app name when no title is set.
Generated public Scaffold pages hide repeated app branding and keep their page heading available to screen readers
without displaying it again below the native toolbar. Browser requests keep their header and visible page heading.
Native buttons, inputs, selects, and table links have a three-rem minimum target height; buttons and table links
also have a three-rem minimum width. Ordinary browser sizing remains unchanged. Browsers show the explicit
collection destination; native requests omit that link and rely on client navigation. Omitted theme selects
light; auto and the native toggle residual follow the WebView's reported color preference.
Material themes apply authored automatic/light/dark appearance, accent, and background colors. The native root owns side cutout/system insets and keyboard space, Hotwire owns the top toolbar inset, and visible Material tabs own the bottom system inset. The root consumes the insets it handles. A single destination reserves bottom system space itself; a full-screen modal reserves any remaining bottom inset because Hotwire hides its tab bar. Material's OnSurface toolbar style keeps close/back icons visible in both light and dark mode. Core assigns Back and Close descriptions through Android string resources after Hotwire installs the toolbar icon. The labels preserve Hotwire's navigation behavior; accessibility hierarchy checks do not establish TalkBack speech or focus order. The launcher icon remains a stock Core asset and is disclosed when Appearance requests a generated icon. Native Account integration, protected entry points, and push remain ungenerated.
Debug accepts APP_ROOT_URL as an Intent string extra, including Android Emulator loopback 10.0.2.2. Release ignores
it and uses the generated HTTPS origin. The Codespaces warning workaround uses Hotwire's public custom-WebView hook
and Android's loadUrl header overload, scoped to the exact configured public Codespaces origin. It does not bypass
private-port authentication. The Core's
upstream comparison (repository-only)
records why no second networking or navigation library is needed and when to retire this small hook.
Initial iOS application lowering
The immutable foundation-plan-rails/ios-application-2026-08 release owns the first pure iOS transforms used by
Compiler composition. It maps all 28 semantic icon tokens to normal and selected SF Symbol names. It
also translates underscores in application.key to hyphens and requires the resulting component to remain one
DNS-safe label of at most 63 ASCII bytes.
A present domain must remain at most 253 ASCII bytes with no label over 63 bytes, must have a final label beginning
with a letter, and cannot use the Compiler-owned .invalid placeholder namespace. The lowering uses it for the
Rails HTTPS origin and reverses its labels for the bundle prefix. Without a domain, it deliberately produces
https://<application-key-component>.invalid and
invalid.firstdraft.<application-key-component>. Current Compilation records do not yet disclose those placeholders;
that external-prerequisite record remains open.
The same release admits one-line display names and encodes display names and closed HTTPS roots so Xcode preserves
literal dollar signs, double slashes, quotes, Unicode, and edge whitespace. Its xcconfig encoder is nested inside
the versioned lowering boundary; changing those bytes requires a successor release and a fresh Xcode audit.
Display-name character policy remains Plan-wide; this release does not add a separate iOS-only Unicode policy.
The release pins the SF Symbol-name bytes, while runtime resolution at the Core's minimum deployment target remains
unproved until the generated-app Simulator smoke.
The SQL-free IosApplication projection consumes one sealed Project generation and its same-generation
ApplicationNavigation result. Ordinary input supplies an Account-free value containing qualified public indexes;
the private native qualifier may instead add Account self. Each value retains the Rails route-collision diagnostics
and source identities needed by Web and native consumers. A declined iOS client carries no application output,
navigation entries, diagnostics, task, Core package,
or native provenance output. A selected client lowers the application identity, origin, Xcode scalars, and every
qualified navigation icon. It retains valid entries beside application-navigation or iOS-specific diagnostics, so a
successful result can navigate only to exact Rails routes admitted for Web emission.
Selected iOS reports source-addressed diagnostics when the application key or domain cannot form the pinned iOS
identity, when the display name cannot fit one admitted Xcode configuration line, or when no valid navigation entry
remains after an otherwise successful application-navigation projection. An upstream failure already explains why
no navigation entry survived, so iOS does not restate that failure as a missing-navigation diagnostic. A missing
domain is not an error. The projection marks the resulting .invalid origin and invalid.firstdraft bundle prefix
as a placeholder identity for later Compilation Record disclosure.
An inadmissible key or domain leaves the selected result without an application value because no identity can be emitted. An inadmissible display name retains the independently lowered identity and origin but leaves the encoded Xcode display name absent. Consumers proceed only from a selected, successful result.
iOS application renderer
IosRenderingContext binds the exact qualified CompilationInput::Result, its exact successful iOS projection,
its exact ApplicationAppearance and public Scaffold-mutation projections, and the lowercase SHA-256 of the exact
Plan snapshot.
CompileCapturedProject serializes that captured Project once, and the active ApplicationManifest coordinator
forwards its digest unchanged. The renderer does not reconstruct provenance from either smaller projection or accept
a reconstructed same-generation value.
Selected iPhone and Android clients cause the complete application README renderer to include their preview-guide links and Rails startup command. The guides retain platform build prerequisites and preview limits. README keeps the private Codespaces port default and routes temporary remote-preview exceptions and cleanup to the selected guide. This is documentation routing, not device or vendor-tool proof. The Core composition owner describes application-document ownership.
The explicit renderer:ios_application family selects no tasks for a declined client and exactly seven one-path
tasks for a selected client. Those paths emit application identity and build settings, navigation definitions, a
bundled path configuration, generated unit and UI tests, and the AccentColor and FoundationBackground color-set
definitions. The generated definition retains the Project ID, graph version, target, profile, lowering release, Plan
digest, placeholder-identity disclosure, and .automatic, .light, or .dark Appearance theme. Entity-derived
files carry the stable UUIDs of their navigation sources. The former aggregate call remains only as a byte-for-byte
differential test oracle.
Scalar authored colors expand to both modes; pairs become exact sRGB light and dark asset entries. Each omitted color
uses its neutral Core value independently: an empty named accent for missing tint and white/black Foundation
backgrounds for missing background. Absent and theme-only Appearance therefore emit both neutral assets. Omitted
theme selects light and emits INFOPLIST_KEY_UIUserInterfaceStyle; auto and the native toggle residual omit that
value and emit .automatic, so iOS follows the system. Core owns the
launch storyboard's FoundationBackground reference and applies the theme, tint, and background to the window,
Hotwire destination controllers, custom Web views, their scroll views, and their under-page color. The generated
client does not duplicate those runtime surfaces.
The bundled configuration begins with Core's default push and pull-to-refresh behavior. One later, query-safe rule
for each ios_navigation root sets presentation: replace_root.
Ordinary public input keeps those native roots Account-free.
This remains true even when browser application_navigation includes /account; only the private native qualifier
may include Account self. Each projection preserves its own authored order. The renderer then consumes the exact
same-generation public Scaffold-mutation projection. An admitted new claim emits the static resource path and an
admitted edit claim emits exactly one record-ID segment, both with an optional query string, context: modal,
and pull_to_refresh_enabled: false. Refresh remains enabled for ordinary pages; form drafts do not acquire a
pull-to-refresh action. Other links retain Core's default push behavior. The renderer does not infer modal routes
from navigation strings or from Scaffold meaning outside that admitted projection.
One navigation entry generates tests for Core's ordinary navigation stack. Two through five entries generate tests for every direct tab and label. With six or more entries, the test expects UIKit's standard iPhone tab-bar shape: the first four entries remain direct tabs and the fifth button opens the More controller. Core starts the overflow navigators through Hotwire's public navigator lookup so a selected More row has content. The other three direct tabs stay lazy. This adds one initial page request per overflow destination. On the pre-iOS-18 delegate path, Core ignores UIKit's More sentinel before forwarding ordinary selections to Hotwire. A current-Simulator regression invokes that legacy callback; an older iOS runtime has not been exercised.
A selected client with no entry cannot render. Before any task renders, IosApplicationTaskPlan retains one exact
IosCorePackage, requires
all seven seams with their expected owners and modes, and rejects missing or undeclared seams. The normal
sequential executor then gives a rendering exception or SQL violation the exact file-task identity. The tasks are
independent and need no DAG. IosCoreComposer receives that same retained package and repeats the seam, owner, and
mode checks before composition. Focused Ruby proof composes the manifest with the pinned Core. It requires the
rendered xcconfig settings, path settings, Swift members, asset catalogs, generated-test contracts, and navigation
initializer labels that the pinned fixture and immutable Core source consume, then removes every standalone fixture
marker. The
renderer is selected only when the exact qualified input selects iOS; a browser-only application has no iOS tasks
and never loads the iOS package. Deriving release versions and build numbers remains deferred. No composed generated
application has run through Xcode or a Simulator as part of hosted or Compilation-lifecycle automation. An opt-in
repository harness now repeats one five-Entity generated Debug application against generated Rails through native
tabs. A dated field
report (repository-only) records one
staff-prepared generated application passing its iOS checks and displaying generated Rails pages in an iPhone 17
Pro Simulator.
A second opt-in harness exercises the privately qualified Movie Catalog Account shape on a copied, instrumented iOS tree. Rails authentication names it.
Core owns a per-launch
APP_ROOT_URL preview seam (repository-only).
Debug accepts an HTTPS host root or HTTP loopback root from the launch environment or -APP_ROOT_URL argument;
Release ignores both and uses the compiled HTTPS origin. The dated Simulator observation used that seam rather than
exercising the emitted production origin.
Core continues to own the window, navigation and tab controllers, transparent scroll-edge appearance, and the
safe-area-aware launch storyboard. It also owns Appearance application from the named launch color through runtime
window and Web-view backgrounds. Generated Swift and color assets supply data to those seams; they do not draw
another header, tab bar, safe-area inset, or Dynamic Island treatment. The AppIcon remains the stock Core asset.
Every Rails shell receives one pre-generated black-on-white SVG/192px PNG/512px PNG bookmark artwork set,
independently of Appearance. The Plan owner describes monogram
selection and its neutral fallback. These ordinary Web files do not provide native launcher artwork.
When iOS is actually emitted, the stock native AppIcon contributes to one
foundation_plan.gap.appearance.icon_assets.not_generated entry with partially_generated status. Android also retains a stock launcher icon when emitted. When neither native
client is emitted, no Appearance icon-assets gap exists. The narrowed disclosure does not erase the generated Web
assets or the generated Rails, iPhone, and Android shell colors and theme.
iOS Core pin and composition
An adjacent implemented source boundary bundles the current iOS Core
pin (repository-only). Its deterministic archive excludes the entire .github
directory, which currently contains one hosted workflow that would be inert inside generated source. IosCorePackage
verifies the archive through the same byte, revision, path, mode, and collision policy as Rails Core.
IosCoreComposer can mount the source under ios/, preserves Core ownership and modes, and writes
ios/FOUNDATION_PROVENANCE.json without changing artifact-envelope format v1. That file records the source, archive
root and exclusion, mounted path, replaced paths, and generated additions so it describes both the archive recipe and
the deliberately composed tree. The composer treats every file under a Core Generated directory as a declared seam,
rejects an undeclared new seam, and requires all seven current application identity, navigation, route-configuration,
application-test, accent-color, and background-color paths from their exact renderer owner with their Core mode. A
pinned whole-output test also rejects the current fixture's operational identity and generated test markers after
composition. The packaged application requires FoundationApp/Generated/ios_v1.json and passes it to Hotwire Native
before the optional server URL. The pinned Hotwire Native 1.3.0 loader applies sources in that order: it loads the
bundled file synchronously, then a cached remote value when present, and starts the remote request asynchronously. A
failed or non-200 request returns without replacing the current configuration, so the absent Rails configuration
endpoint does not block application startup. This boundary deliberately does not add that endpoint.
The pinned Core now advertises an iPhone-only device family. Its Simulator tests exercise the real
single-navigator and native-tab containers against a Core-owned sentinel document. UIKit and Hotwire Native own
the native safe-area adjustment, so composed Rails output must retain ordinary viewport behavior instead of
adding generic viewport-fit=cover plus env(safe-area-inset-*) padding. Those tests constrain Core; a composed
Rails smoke remains necessary. iPad qualification is deliberately deferred in
foundation-ios-core#2 (repository-only) until the navigation and
safe-layout contract is designed and exercised there.
IosApplication consumes the exact same-generation iOS navigation result. It maps semantic icon tokens to the pinned
SF Symbols only after iOS selection and permits Account self to be the sole native entry only for the private native
qualifier. Its entries keep the target-neutral stable identity, route, label, rank, and source provenance. The task
family reads the sealed Project only to prove the exact context generation and performs no SQL. The independent
package pin belongs to the Compiler's provisional native Capability rather than the Rails naming profile.
Release-version and build-number derivation and the optional Rails configuration endpoint remain later work.
Appearance is consumed by Rails and selected native shells. Rails derives the Web icon pair, while emitted native clients retain one narrowed stock-launcher-icon disclosure. iPhone and Android lower independently; delivery is pruned at import. Each ordinary selected native client still requires at least one admitted non-Account navigation entry; Account self may be the sole entry only for the private native qualifier. With no iOS selection, browser application navigation remains Web input while iOS declines without output.
iOS forms and readable content
The first controller in a modal stack supplies UIKit's localized Cancel control when no custom left item exists. Cancel dismisses; the Rails form's Create or Update button saves. A pushed modal controller keeps Back. Core replaces Hotwire's Done item because its checkmark looked like a save action while only dismissing the form. This adds no draft tracking or discard-confirmation bridge. The Rails form also retains its Cancel link to the explicit collection or return URL. That destination may differ from dismissing the native modal stack, so both controls remain; the native item selects no Rails destination.
UIKit and Hotwire retain ownership of physical-screen, navigation-bar, tab-bar, and home-indicator insets. Core
anchors Hotwire's public visitableView below the navigation bar with view.safeAreaLayoutGuide.topAnchor; its
bottom remains at view.bottomAnchor, with automatic scroll insets retained. The WebView scroll view uses
keyboardDismissMode = .onDrag. The local landscape trial kept the focused Rails field below the toolbar, then
dismissed the software keyboard by dragging from that field and exposed the complete Create action. No CSS
safe-area padding, keyboard-height calculation, or JavaScript keyboard bridge participates.
Rails Core uses WebKit's supported
font: -apple-system-body on the native root
so Dynamic Type scales rem text, controls, and spacing together. The observed Simulator root was 17px at default
size and 53px at the largest accessibility size. The rule requires the native body marker and both system-font and
:has() support; otherwise existing root styling
remains. Ordinary browser and Android roots retain their sizing. The CSS cascade preserves configured font family
and line spacing; application-authored fixed pixel sizes do not inherit the scale.
Native buttons wrap within their container and grow vertically while retaining their 3rem minimum and larger
component sizes. Basecoat and React controls share the native sizing policy. Generated Rails submit actions use form.button, because
the Simulator trial's single-line submit inputs could not wrap the full Create Location label. Browser buttons
retain the selected Vega component sizing. The
iOS interaction report (repository-only)
separates temporary probes from passing ordinary-Compilation runs at default and largest accessibility text sizes.
These observations
do not establish physical-device, older-iOS, or general accessibility conformance.
Browser preview with Revyl
Revyl is the primary native preview for iPhone and Android, in a Codespace and on a local computer, by the
2026-09-30 decision (repository-only). Locally, a cloudflared quick
tunnel gives Rails a public HTTPS address. Xcode's Simulator and Android Studio's Emulator are documented
alternatives. The packaged Skill, the emitted preview guides, and the local guide still present the earlier order
until a later round of user-facing documentation. Leave the local guide to the tester guide now in progress.
The local guide covers local Rails, local native builds, and direct Revyl uploads. The September 22 trial (repository-only) loaded both clients through Cloudflare Tunnel. Android required an owner-updated WebView; its forms and navigation were not independently repeated. The stock Pixel 7 image observed in that trial had WebView 113, below Hotwire's minimum of 120.
Android Studio's local Emulator with a compatible WebView is the Android alternative, and the fallback when a hosted
device reports a WebView older than 120. Rails can also stay in a Codespace behind a private GitHub CLI
port-forward; the Emulator reaches that local port through 10.0.2.2.
The generated Android guide includes setup and Debug launch flags. The private-tunnel observation (repository-only)
proved the connection with an existing APK and Rails app. The later
assisted Codex trial (repository-only) exercised integrated-terminal
bin/dev, a fresh clone opened in newly installed Android Studio, a local build and Emulator interactions, and
Rails-only refresh without rebuilding the APK. Studio's bundled Java 25 worked; forcing its Gradle JVM criteria to
17 failed. The guide retains Studio's default and separately documents JDK 17 for CLI/CI. Core's bytecode target
remains Java 17.
The local Emulator does not require Revyl build or device usage.
Selected output also includes Codespaces preview instructions: IOS_PREVIEW.md (repository-only) and
bin/ios preview revyl for iPhone, or ANDROID_PREVIEW.md (repository-only) and
bin/android preview revyl for Android. The emitted Android guide still leads with the Emulator. For Revyl, start
testers at its “Revyl device prerequisite” section, which holds the WebView check, then at its “Optional Revyl
preview on a compatible device” section. GitHub builds an unsigned Debug Simulator artifact on a Mac runner for
iPhone and a standalone debuggable APK on an Ubuntu runner for Android when native source is pushed. Rails-only
commits reuse an ancestor's artifact when all native build inputs match. Revyl runs the uploaded artifact against a
public HTTPS Rails preview through APP_ROOT_URL. Students keep most Rails iteration in their web preview and stop
Revyl after native checks. GitHub runner allowance and Revyl device usage are separate; this path uses no Revyl
remote-build compute.
Install the latest Revyl CLI with its official installer. The preview helper checks authentication and the app API
before requesting a build; doctor reports the installed CLI version without enforcing exact equality. The owner
accepts ordinary CLI drift: a future command change can be adapted in the generated application. A version bound
needs an observed incompatibility, rather than an untested release alone. Commands and JSON shapes were checked
against v0.1.129 source without a new provider session.
The Codespaces token can push and download artifacts while denying workflow dispatch. The guide therefore retains
an Actions-page fallback for an expired artifact. Debug WebKit loads to the configured Codespaces origin skip its
non-Turbo warning page with the documented tunnel header. The port must still be public, and Release behavior does
not change. The scoped request decoration lives in Core's
AppWebView (repository-only).
Preview configuration holds no credentials and does not require a Revyl GitHub integration. The unsigned artifact
carries no Apple signing identity, and Decision 0008 (repository-only) keeps Revyl
a replaceable adapter over it. Physical iPhone installation is a separate path.
The September 10 smoke (repository-only) preserves provider repairs and the ecosystem comparison. Revyl previews have shown fresh deployed output and sign-in; reliable restart and browser drag and Back remain unshown.
Emitted IOS_PREVIEW.md still teaches pull-down refresh and waiting before a restart.
The September 12 smoke at 651d8d62 showed why those instructions need supplements: browser drags did not refresh,
while this CLI swipe refreshed the default portrait preview:
revyl device swipe down --x 210 --y 310 --duration 1200
Its wait-for-completion advice also did not resolve a restart rejection: Revyl reported its concurrency limit after the first session was Completed and the dashboard showed zero active devices. If that happens, stop and record the error rather than repeatedly retrying or upgrading to continue. The cause remains unresolved. These corrections supplement the emitted iOS guide. The Android Studio follow-up changes no iOS template or runtime and adds no new Revyl observation.
Navigation and tabs
Native does not own which surfaces are top-level. The implemented main navigation (repository-only)
derives browser navigation from admitted public, item-authorized, and environment-gated Scaffold entries plus one
Account entry. Any realized Account contributes account-self:<Account subject_uuid>, the semantic person token,
and /account whether or not iOS is selected. The browser shows Account after resource entries and before Sign out
when current_account is present. Authored profile details compose into that destination under their own read Policy.
On the ordinary path, a selected iOS client consumes a separate Account-free ios_navigation with public-only
entries; only the private native qualifier may carry Account self into one-stack or tabbed navigation. It receives
neither custom Account details nor protected Movie/Bookmark entries. The Compiler records their precise native
consequences. An omitted iOS client emits no iOS output.
The lowering needs two things the derived list does not carry.
An icon per entry. Each public index takes its glyph from the optional icon on the Entity behind it, and the
Account self entry takes its fixed person token. Icons are semantic tokens rather than platform names,
in the same way Field types are semantic tokens rather than Ruby classes. A separately versioned platform lowering
maps each token to an SF Symbol on iOS or a Material icon on Android.
That token vocabulary has to be a closed set. An open string would let an author write a name that resolves on
one platform and silently falls back on the other, which is the kind of per-platform divergence this Capability
exists to prevent. Curating the mapping is real platform-lowering work that no icon design avoids. Shared main
navigation turns an omitted Entity icon into the neutral grid token, and the versioned iOS lowering maps that
token to square.grid.2x2 and its selected variant.
A badge, with its count left to ordinary source. The Compiler emits the badge machinery and every entry can carry one; the number comes from application code at render time rather than from the Plan.
Ruby Native draws the same boundary. Its configuration carries only a per-tab boolean, while the count arrives
through a native_badge_tag(count, home:, tab:) view helper. The value that changes per request stayed out of
the static config, and the same reasoning applies here.
Binding a badge to a Predicate was considered. It is expressible today, since a Predicate can compare against
{ "kind": "environment", "name": "current_account" } as Shinar already does. Shinar's unread messages and
Dunbar150's notifications are both natural badges. It was declined for now because a live COUNT on every render
sits badly with the profile's strict_loading and flat-query-count discipline, so a declarative badge would
quietly imply a counter or derived Field as well. Revisit once the
causal seam (repository-only) closes and an inbox is a Plan subject, which would make the
binding express real meaning rather than a query shortcut.
An application with one navigation entry has no tab bar. The client opens that entry directly, which matches Ruby
Native's behavior when its tabs key is omitted. A selected iOS client with no entry is invalid even though an empty
main-navigation result remains valid for the web boundary.
Path configuration
ios_v1.json and android_v1.json are derived output. The Scaffold routes already say which URLs are single-record
forms. Hotwire Native's demo configuration maps the bare new and numeric-edit patterns; the optional query-safe
suffix is this Compiler's addition:
| Derived from | Emitted rule |
|---|---|
Public new or edit claim |
context: modal, pull_to_refresh_enabled: false; optional query string |
| A direct navigation entry's route | presentation: replace_root at that root, with or without a query string |
| Every other path | default push navigation |
The native_ios and native_android probes in the
support inventory (repository-only) check the emitted
replace_root and default push rules in each client's path configuration. No probe exercises the modal rule yet.
Pinned Hotwire Native 1.3.0 enables query-string matching by default and evaluates rules against
path?query
when a query is present. The emitted optional query suffix keeps pagination or filter URLs at a navigation root under
replace_root, rather than adding a Back entry for another state of the same root index. It likewise keeps an
admitted form modal when its URL carries a query. Focused Compiler tests prove the emitted expressions; the current
generated-app observation did not tap a query-bearing root or form URL.
Android 1.3.1 independently matches
path?query.
Its rule matcher
uses Kotlin regular expressions. The same optional query suffix therefore preserves direct-root and modal
classification on Android; this is a separate source comparison from the iOS one.
The pinned Navigator.start()
applies path-configuration properties to the initial tab URL. clear_all ignores the proposed controller and
refreshes an existing root, so it can leave that initial empty stack blank. The pinned
replaceRoot
visits the proposed controller and installs it as the root. That behavior fits both first launch and a later link
back to a navigation root.
Both clients recognize context with a modal
value and presentation with a replace_root value, alongside pull_to_refresh_enabled,
query_string_presentation, and historical_location.
The wider vocabularies differ, which is why the first lowering deliberately uses a small subset:
| Key | Both clients | iOS only | Android only |
|---|---|---|---|
context |
default, modal |
— | — |
presentation |
default, pop, replace, refresh, clear_all, replace_root |
none |
push |
modal_style |
— | five sheet styles | — |
uri |
— | — | registered Hotwire fragment destination |
title |
— | — | More supplies a native title |
pull_to_refresh_enabled |
Boolean; default true, admitted forms false |
— | — |
modal_style is an iOS concept with no Android counterpart, so authoring sheet detents would produce output one
platform silently ignores. Check each platform's pinned property accessors and destination registry before extending
its emitted rules; a platform-specific key need not appear in the other client's configuration.
Android's default URI is hotwire://fragment/web; admitted new/edit forms retain it with context: modal
and pull_to_refresh_enabled: false. The
pinned demo configuration
uses the same full-screen form pattern. Core also registers an optional bottom sheet and
hotwire://fragment/firstdraft-more.
With more than five entries, /__firstdraft_android_more__ selects the latter with title More. Its overflow routes
keep default push behavior so Back returns to More; only the first four direct roots receive replace_root.
Ordinary browsers render the same Rails new and edit routes as pages. Native path configuration selects their
modal presentation; it does not convert browser markup into a modal or remove the Rails header.
The pinned iOS Core requires a bundled FoundationApp/Generated/ios_v1.json and supplies its file URL before
/configurations/ios_v1.json in Hotwire Native's source list. The pinned
Hotwire Native 1.3.0 loader
loads sources in order: the bundled file is synchronous, a cached remote value may replace it, and the server
request is asynchronous. A failed or non-200 request returns without updating the active configuration. The
bundled rules therefore remain usable when the optional remote endpoint is absent or unavailable. The active
Compiler slice does not generate that Rails endpoint; adding it remains a separate Rails-side lowering.
Android instead supplies only the bundled app/src/main/assets/json/android_v1.json, available synchronously
before navigation. It requests no remote configuration endpoint.
Primary source and ownership
The current direction treats the Xcode and Android projects as primary editable source. A generic precompiled First Draft shell would test a different ownership model.
The first owner journey should try an ordinary path to:
- build with local platform tools or a replaceable build provider;
- choose and control signing identities;
- install on owned devices when platform requirements are met;
- distribute through owner-controlled store records;
- configure push credentials and application identifiers; and
- continue with any capable agent or developer.
The in-progress Hotwire Native agent skill is an output and test surface of this work, not an input or authority for the Capability design.
Distinct delivery lanes
Native evidence is reported by job:
| Lane | What it can establish | What it does not establish |
|---|---|---|
| Source generation | Editable projects and integration exist | Either project builds or runs |
| Automated build/test | The pinned project compiles and its tests pass | Interactive behavior or device-only APIs |
| Simulator preview | Rendering, navigation, Bridge, notification UI | Signing, phone install, push, distribution |
| Owner-signed device | Real-device lifecycle and eligible capabilities | TestFlight/App Store or production receipt |
| Beta/store | Owner-controlled installation | Push receipt, background, or tap routing unless exercised |
| Push lifecycle | Registration, receipt, app states, taps, token cleanup | Equivalent behavior on other platform |
An Apple Account can be used for limited Xcode device testing, while TestFlight, App Store distribution, and advanced app services are associated with Apple Developer Program membership. Current details must be checked against Apple's membership comparison and program benefits when implementing or marketing a lane. The recovered native generation mechanics (repository-only) hold a dated July 2026 snapshot of Google's developer-verification and account landscape. Recheck current requirements before designing or making claims about an Android ownership lane.
Push semantics
Push machinery is activated by an authored application-level fact rather than derived, because no Plan subject can imply it. Decision 0016 (repository-only) records why, and requires an Account-bearing Entity before the fact is valid.
Machinery now, rules later
The first lowering emits the machinery and no notification rules. That split is forced by the causal seam (repository-only): nothing in the Plan can yet say that a successful operation should notify a recipient, because Scaffolds model forms without event emission and state-machine effects reach only same-transition Field assignments.
What the first lowering can emit:
- Action Push Native, its device table, and the registration handshake;
- permission prompting after sign-in rather than at launch;
- token storage, replacement, and invalid-token cleanup;
- a test-send surface on the profile page; and
- credential placeholders plus the external prerequisites the owner must satisfy.
photogram-golden demonstrates the shape with app/controllers/account/push_test_notifications_controller.rb,
so this is extraction from working reference source rather than fresh design.
A Foundation that registers devices and can send one test notification proves the whole lifecycle end to end while carrying no rule the format cannot express. When the causal seam closes, notification rules name their own channels and this fact converts to a derived consequence.
Push needs a client to deliver to
ios_push and android_push are separate application-level facts, and each needs its own platform's client. A
Plan enabling android_push while declining native.android registers devices that cannot exist, so semantic
validation should reject that pairing rather than emit a registration handshake with nothing behind it.
The reverse pairing is legitimate. Shipping a client while deferring that platform's push credentials is a reasonable early position, especially for Android, whose FCM lane has no evidence yet.
Browser Push is not a native transport and carries no client dependency. Noticed 3.0.0 has no web-push delivery
method, so web_push would arrive through the separate web-push gem, which the profile has not admitted.
Device storage belongs to the Capability
Action Push Native supplies action_push_native_devices with name, platform, token, and a polymorphic
owner. A Plan that also models a device Entity describes the same table twice, so Shinar's device_installation
Entity was removed once the delivery facts made registration a Capability concern.
A user-visible device list is a different question. "See and revoke your signed-in devices" is product meaning that Action Push Native does not supply, and an application wanting it would model that surface deliberately rather than as a side effect of enabling push. Nothing in the current format prevents it; no fixture asks for it yet.
Lifecycle coordination
The candidate implementation needs to coordinate:
- permission timing as product behavior;
- installation and account binding;
- platform environment and application identity;
- safe logout, account changes, invalid-token cleanup, and replacement;
- after-commit observable delivery work;
- provider errors and retry/discard behavior;
- foreground, background, terminated, and routed-open behavior; and
- protection of tokens, credentials, and sensitive payload data.
Registering a token is not end-to-end push proof.
The application-level notification design (repository-only) owns why one recipient should receive push. This Capability owns native endpoint and client lifecycle. Browser Web Push remains a separate adapter.
Revisit
Reconsider Hotwire Native or this topology when generated apps need substantially different client interfaces, shared web UI becomes a continuation liability, platform policy breaks an ownership lane, or controlled evidence favors another native architecture.