diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index 46d54ed4233..3980a30b44d 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -71,3 +71,11 @@ All code should maximize readability and simplicity. - ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible. - Complex validations and business logic should remain in ActiveRecord +### Hotwire Native Prototype Guidance + +- Consult [docs/hotwire_native_prototype_plan.md](mdc:docs/hotwire_native_prototype_plan.md) before starting native wrapper work. +- Create a dedicated branch named `feature/hotwire-native-prototype` from `main` for prototype tasks and keep unrelated changes out. +- Every change that targets the native wrapper must also be validated in the existing PWA to maintain parity. +- Turbo Native user agents are auto-detected via `TurboNative::Controller`; rely on the `:turbo_native` layout variant rather than bespoke conditionals in views. +- Keep navigation definitions in `ApplicationHelper#primary_navigation_items` so both the web layout and the native bridge stay synchronized. + diff --git a/AGENTS.md b/AGENTS.md index 9788d9212c4..d10136b8ac8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,13 @@ - Lint/format JS/CSS: `npm run lint` and `npm run format` — uses Biome. - Security scan: `bin/brakeman` — static analysis for common Rails issues. +## Mobile Prototype Workflow +- Planning artifacts for the Hotwire Native prototype live in `docs/hotwire_native_prototype_plan.md`. Review and update the checklist before starting native-wrapper work. +- Implementation spikes for the native wrappers must originate from the `feature/hotwire-native-prototype` branch cut off the latest `main` once planning is approved. +- Keep PWA regressions in mind: always confirm changes behave in both the web PWA and Turbo Native contexts. +- Turbo Native requests are detected via `TurboNative::Controller`; when you add new controllers ensure they inherit from `ApplicationController` (or include the concern) so the `:turbo_native` layout variant is applied automatically. +- The native layout exports navigation metadata through `ApplicationHelper#turbo_native_navigation_payload` and the `turbo_native_bridge` Stimulus controller. When adding tabs or nav links, update the helper to keep web and native shells in sync. + ## Coding Style & Naming Conventions - Ruby: 2-space indent, `snake_case` for methods/vars, `CamelCase` for classes/modules. Follow Rails conventions for folders and file names. - Views: ERB checked by `erb-lint` (see `.erb_lint.yml`). Avoid heavy logic in views; prefer helpers/components. diff --git a/CLAUDE.md b/CLAUDE.md index 575ba82c55d..a46d3b35903 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,13 @@ Only proceed with pull request creation if ALL checks pass. - Do not run `rails credentials` - Do not automatically run migrations +### Hotwire Native Prototype Process +- Review `docs/hotwire_native_prototype_plan.md` before beginning any native wrapper work. +- Cut a fresh branch named `feature/hotwire-native-prototype` from `main` for all prototype changes; keep it focused on the Turbo Native spike. +- Ensure features continue to function for both Turbo Native wrappers and the existing PWA before submitting a PR. +- Controllers automatically detect native requests via `TurboNative::Controller`; lean on the `turbo_native_app?` helper instead of inspecting headers manually. +- Keep navigation data centralized in `ApplicationHelper#primary_navigation_items` so the `turbo_native_bridge` controller can mirror updates inside the native shells. + ## High-Level Architecture ### Application Modes diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 260579d1771..a983fe78215 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,7 @@ class ApplicationController < ActionController::Base include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, - FeatureGuardable, Notifiable + FeatureGuardable, Notifiable, TurboNative::Controller include Pagy::Backend diff --git a/app/controllers/concerns/turbo_native/controller.rb b/app/controllers/concerns/turbo_native/controller.rb new file mode 100644 index 00000000000..2cff9eda421 --- /dev/null +++ b/app/controllers/concerns/turbo_native/controller.rb @@ -0,0 +1,32 @@ +module TurboNative + module Controller + extend ActiveSupport::Concern + + TURBO_NATIVE_USER_AGENT = /Turbo\s?Native/i + TURBO_NATIVE_HEADER = "Turbo-Native" + TURBO_VISIT_CONTROL_HEADER = "Turbo-Visit-Control" + + included do + before_action :set_turbo_native_variant + helper_method :turbo_native_app? + end + + private + def set_turbo_native_variant + request.variant = :turbo_native if turbo_native_app? + end + + def turbo_native_app? + return @turbo_native_app unless @turbo_native_app.nil? + + @turbo_native_app = turbo_native_user_agent? || + request.headers[TURBO_NATIVE_HEADER].present? || + request.headers[TURBO_VISIT_CONTROL_HEADER] == "native" + end + + def turbo_native_user_agent? + user_agent = request.user_agent.to_s + user_agent.match?(TURBO_NATIVE_USER_AGENT) + end + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index fed96b56e77..fd4f45dc70d 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -40,6 +40,7 @@ def create def destroy @session.destroy + response.set_header("Turbo-Visit-Control", "reload") if turbo_native_app? redirect_to new_session_path, notice: t(".logout_successful") end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3e7fe1b4293..f6d027df96c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -57,6 +57,29 @@ def page_active?(path) current_page?(path) || (request.path.start_with?(path) && path != "/") end + def primary_navigation_items + [ + nav_item("Home", root_path, "pie-chart"), + nav_item("Transactions", transactions_path, "credit-card"), + nav_item("Reports", reports_path, "chart-bar"), + nav_item("Budgets", budgets_path, "map"), + nav_item("Assistant", chats_path, "icon-assistant", icon_custom: true, mobile_only: true) + ] + end + + def turbo_native_navigation_payload + primary_navigation_items.map do |item| + { + title: item[:name], + url: item[:path], + icon: item[:icon], + icon_custom: item[:icon_custom], + mobile_only: item[:mobile_only], + active: item[:active] + } + end + end + # Wrapper around I18n.l to support custom date formats def format_date(object, format = :default, options = {}) date = object.to_date @@ -123,6 +146,17 @@ def markdown(text) end private + def nav_item(name, path, icon, icon_custom: false, mobile_only: false) + { + name: name, + path: path, + icon: icon, + icon_custom: icon_custom, + mobile_only: mobile_only, + active: page_active?(path) + } + end + def calculate_total(item, money_method, negate) # Filter out transfer-type transactions from entries # Only Entry objects have entryable transactions, Account objects don't diff --git a/app/javascript/controllers/turbo_native_bridge_controller.js b/app/javascript/controllers/turbo_native_bridge_controller.js new file mode 100644 index 00000000000..02fb783c206 --- /dev/null +++ b/app/javascript/controllers/turbo_native_bridge_controller.js @@ -0,0 +1,84 @@ +import { Controller } from "@hotwired/stimulus"; + +const messageHandlers = [ + (payload) => window.webkit?.messageHandlers?.hotwireNative?.postMessage?.(payload), + (payload) => window.HotwireNative?.postMessage?.(payload), + (payload) => window.HotwireNativeBridge?.postMessage?.(payload), +]; + +export default class extends Controller { + static values = { + navigation: Array, + activePath: String, + }; + + connect() { + this.visitListener = this.handleVisitRequest.bind(this); + this.boundHandleTurboLoad = this.handleTurboLoad.bind(this); + + document.addEventListener("hotwire-native:visit", this.visitListener); + document.addEventListener("turbo:load", this.boundHandleTurboLoad); + + window.hotwireNative ||= {}; + window.hotwireNative.visit = (url, options = {}) => { + if (!url) return; + window.Turbo?.visit(url, options); + }; + + this.publish({ event: "connect" }); + } + + disconnect() { + document.removeEventListener("hotwire-native:visit", this.visitListener); + document.removeEventListener("turbo:load", this.boundHandleTurboLoad); + } + + navigationValueChanged() { + this.publish({ event: "navigation:update" }); + } + + activePathValueChanged() { + this.publish({ event: "location:update" }); + } + + handleTurboLoad() { + this.publish({ event: "visit" }); + } + + handleVisitRequest(event) { + const { url, options } = event.detail || {}; + if (!url) { + return; + } + + window.Turbo?.visit(url, options || {}); + } + + publish({ event }) { + const payload = { + event, + url: window.location.href, + path: this.activePathValue || window.location.pathname, + title: document.title, + navigation: this.navigationValue || [], + }; + + document.dispatchEvent( + new CustomEvent("hotwire-native:bridge", { detail: payload }), + ); + + messageHandlers.some((handler) => { + if (typeof handler !== "function") { + return false; + } + + try { + handler(payload); + return true; + } catch (error) { + console.warn("Failed to notify native bridge", error); + return false; + } + }); + } +} diff --git a/app/views/layouts/application.html+turbo_native.erb b/app/views/layouts/application.html+turbo_native.erb new file mode 100644 index 00000000000..99ea9153f87 --- /dev/null +++ b/app/views/layouts/application.html+turbo_native.erb @@ -0,0 +1,16 @@ +<% native_navigation_payload = turbo_native_navigation_payload %> + +<% content_for :head do %> + <%= tag.meta name: "turbo-native", content: "true" %> + <%= tag.meta name: "turbo-native-navigation", content: native_navigation_payload.to_json %> +<% end %> + +<%= render "layouts/shared/htmldoc" do %> + <%= tag.div class: "min-h-full bg-surface", data: { + controller: "turbo-native-bridge", + turbo_native_bridge_navigation_value: native_navigation_payload.to_json, + turbo_native_bridge_active_path_value: request.fullpath + } do %> + <%= yield %> + <% end %> +<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 796c55aeb83..fc8f0118e22 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,10 +1,4 @@ -<% mobile_nav_items = [ - { name: "Home", path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) }, - { name: "Transactions", path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) }, - { name: "Reports", path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) }, - { name: "Budgets", path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) }, - { name: "Assistant", path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true } -] %> +<% mobile_nav_items = primary_navigation_items %> <% desktop_nav_items = mobile_nav_items.reject { |item| item[:mobile_only] } %> <% expanded_sidebar_class = "w-full" %> diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 8dd683d4bea..036030d3a42 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -4,7 +4,15 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> - <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + <% if Rails.env.test? %> + <% begin %> + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + <% rescue StandardError => e %> + <% Rails.logger.debug { "tailwind.css unavailable in test: #{e.message}" } %> + <% end %> + <% else %> + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + <% end %> <%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %> <%= combobox_style_tag %> diff --git a/docs/hosting/native.md b/docs/hosting/native.md new file mode 100644 index 00000000000..17b525150fd --- /dev/null +++ b/docs/hosting/native.md @@ -0,0 +1,126 @@ +# Turbo Native Hosting Guide + +This guide explains how to build and ship the iOS and Android shells that host the Sure web experience via Hotwire Native. It assumes you have already cut the `feature/hotwire-native-prototype` branch and merged the Rails changes documented in `docs/hotwire_native_prototype_plan.md`. + +## 1. Account & Access Checklist + +| Platform | Required Accounts | Notes | +| --- | --- | --- | +| Apple iOS | Apple Developer Program (Individual or Company) | One team owner must invite the rest of the mobile crew via App Store Connect. Enable access to Certificates, Identifiers & Profiles. | +| Google Android | Google Play Console & Google Cloud project | Grant release managers the "Release manager" role and ensure a linked Firebase project for crash reporting (optional but recommended). | +| Source Control | GitHub (or host of record) access to the `feature/hotwire-native-prototype` branch | Keep Rails and native wrappers in sync by rebasing on `main` before every release build. | +| Distribution | TestFlight and Google Play Internal testing tracks | Set up at least one internal track per platform for QA builds. | + +## 2. Local Development Environment + +### Shared prerequisites + +1. **Ruby & Node** – Match the versions declared in `.ruby-version` and `.node-version`. Run `bin/setup` once to install gems, npm packages, and prepare the Rails application. +2. **Hotwire Native CLI** – Install via `gem install hotwire-native` (requires Ruby 3.1+). This provides the `hotwire-native ios` and `hotwire-native android` commands used below. +3. **Environment files** – Copy `.env.local.example` to `.env.native`. Populate API hosts, feature flags, and any third-party keys required by the mobile shell. Do **not** commit secrets; use 1Password or the shared vault to distribute them. +4. **HTTPS tunnel** – For simulator work, expose your Rails dev server over HTTPS (e.g., `cloudflared tunnel` or `ngrok http https://localhost:3000`). The native apps refuse clear-text HTTP when ATS/Network Security Config is enabled. + +### macOS requirements (iOS builds) + +- macOS 13 Ventura or newer. +- Xcode 15.x with the Command Line Tools installed (`xcode-select --install`). +- CocoaPods (`sudo gem install cocoapods`) for dependency syncing. +- Homebrew (optional) for installing simulators and utilities. + +### Windows/Linux requirements (Android builds) + +- Android Studio Giraffe+ with Android SDK Platform 34. +- Java 17 (Android Gradle Plugin 8 requires it). +- `adb` available in your `$PATH`. +- Gradle managed through Android Studio (wrapper checked into the native repo). + +## 3. Repository Layout + +The prototype keeps the native wrappers under `native/` alongside the Rails app: + +``` +native/ +├── ios/ # Xcode workspace generated by hotwire-native +├── android/ # Gradle project for the Android shell +└── README.md # Shared notes specific to the wrappers +``` + +If `native/` does not exist yet, initialize it with: + +```sh +hotwire-native init --platforms=ios,android --path=native \ + --app-name "Sure" \ + --server-url "https://dev.sure.example" # replace with your tunnel URL +``` + +Commit the scaffolding to the prototype branch so others can collaborate. + +## 4. Configuring the WebView bridge + +1. Update `native/ios/Sure/Info.plist` and `native/android/app/src/main/AndroidManifest.xml` with the production domain (`https://app.sure.com`). +2. Ensure the Rails layout `app/views/layouts/application.html+turbo_native.erb` serves navigation payloads. No code change is required, but verify the JSON includes the tabs expected by the native shell. +3. Add your HTTPS tunnel domain to the allow-list during development: + - **iOS**: `NSAppTransportSecurity -> NSAllowsArbitraryLoadsInWebContent = YES` for the tunnel hostname only. + - **Android**: Update `res/xml/network_security_config.xml` to allow the dev hostname over HTTPS. + +## 5. Building the iOS app + +```sh +cd native/ios +pod install +xed . # or open Sure.xcworkspace manually +``` + +1. Select the `Sure` scheme. +2. Choose a signing team that matches your Apple Developer account. +3. Configure the bundle identifier under *Signing & Capabilities* (e.g., `com.sure.app` for production, `com.sure.dev` for staging). +4. Update the build settings: + - **Server URL**: Edit `Config.xcconfig` to point to your desired environment (`https://staging.sure.com`). + - **App Icon & Assets**: Replace the placeholder images in `Assets.xcassets`. +5. To run in the simulator, press **⌘R**. For a physical device, ensure it is registered in the Developer portal and use **Product → Destination** to select it. +6. Create an archive (**Product → Archive**) and upload to TestFlight using the Organizer. Document the build number in the release notes. + +## 6. Building the Android app + +```sh +cd native/android +./gradlew wrapper +./gradlew assembleDevDebug # dev tunnel build +./gradlew assembleRelease # production-ready build (requires keystore) +``` + +1. In Android Studio, open the `native/android` folder. +2. Set the application ID in `app/build.gradle` (`applicationId "com.sure.app"`). Use suffixes like `.dev` for QA channels. +3. Update `app/src/main/res/xml/remote_config_defaults.xml` or equivalent to point to the correct server URL. +4. Configure signing: + - Place the keystore under `native/android/keystores/` (ignored by git). + - Add a `keystore.properties` file (ignored) with `storeFile`, `storePassword`, `keyAlias`, `keyPassword`. + - Reference the file in `build.gradle` to enable release signing. +5. Verify the WebView bridge loads the Turbo Native layout by launching `devDebug` on an emulator (`Run > Run 'app'`). +6. Use the Play Console Internal track for QA: upload the `app-release.aab` generated by `./gradlew bundleRelease`. + +## 7. Continuous Integration (Optional) + +For automated builds, configure CI jobs to: + +- Check out the Rails repo and run `bin/rails test` to ensure the web bundle is healthy. +- Cache `native/ios/Pods` and Android Gradle caches between runs. +- Use `xcodebuild -workspace Sure.xcworkspace -scheme Sure -archivePath ... archive` for iOS. +- Use `./gradlew bundleRelease` for Android. +- Store signed artifacts in the build pipeline or upload to App Store Connect / Play Console via API keys. + +## 8. Release Checklist + +1. Verify the Rails backend has been deployed with the matching commit hash. +2. Smoke-test the PWA to ensure parity with the native wrappers. +3. Update the in-app version string (Settings → About screen) to reflect the build. +4. Publish release notes describing the Turbo Native changes. +5. Coordinate rollout percentages (start with 5%, monitor, then ramp). + +## 9. Support & Troubleshooting + +- **Authentication loops**: Confirm `Turbo-Visit-Control` headers are present on sign-out routes and that cookies share the same domain between web and native shells. +- **Push notifications**: The prototype does not ship push support; add Firebase Cloud Messaging / APNs later. +- **Performance**: Use `WKWebView` and `androidx.webkit.WebViewCompat` debugging tools to profile slow pages. Enable remote debugging via Safari DevTools or `chrome://inspect`. + +Keep this document updated as the prototype graduates from experimentation to production. Contributions should include simulator screenshots and updated instructions when the workflow changes. diff --git a/docs/hotwire_native_prototype_plan.md b/docs/hotwire_native_prototype_plan.md new file mode 100644 index 00000000000..634592f0878 --- /dev/null +++ b/docs/hotwire_native_prototype_plan.md @@ -0,0 +1,66 @@ +# Hotwire Native Prototype Plan + +## Overview +- Evaluate how the existing PWA experience can be wrapped inside Hotwire Native shells for iOS and Android clients. +- Deliver a focused prototype branch that proves the Rails + Hotwire stack can expose the Turbo-powered flows required by the wrapper applications without disrupting the web PWA. +- Produce clear implementation guidance so subsequent work streams can execute quickly. + +## Goals +1. Validate that our current Turbo stream usage maps cleanly onto the Hotwire Native navigation model. +2. Document the configuration required in Rails to support Turbo Native (custom user agent detection, native-specific navigation fallbacks, authentication flows). +3. Ship a thin spike demonstrating native navigation + authentication for a single high-value flow (account overview) while preserving PWA behavior. + +## Branch Strategy +- Create branch: `feature/hotwire-native-prototype` off the latest `main` once approved. +- Keep the branch short-lived and focused on enabling Hotwire Native wrappers; avoid unrelated refactors. +- Land planning artifacts (this doc + tooling updates) on `work`, then branch from `main` for implementation work. + +## Research Checklist +- [x] Review the official [Hotwire Native announcement](https://dev.37signals.com/announcing-hotwire-native/) for required gem versions and navigation primitives. +- [x] Audit existing Turbo usage (frames, streams) for compatibility with the native navigation stack. +- [x] Confirm PWA assets (manifest, service worker) do not conflict with Turbo Native user agents. +- [x] Identify areas needing native-only navigation or modal handling (e.g., file uploads, OAuth redirects). + +## Prototype Scope +1. **Authentication** + - Ensure Turbo Native requests receive the same CSRF/session handling as web requests. + - Add native-only sign-out redirect handling if necessary. +2. **Navigation shell** + - Implement native navigation menus using Turbo Native visit proposals. + - Verify deep links from push notifications or emails open correctly in native wrapper. +3. **Account overview flow** + - Confirm account listing, detail, and transactions operate within Turbo frames on native clients. + - Identify any Stimulus controllers that require native-specific tweaks (file uploads, clipboard, etc.). + +## Rails Changes (Prototype) +- Add middleware or request variant detection (`request.user_agent`) to differentiate Turbo Native clients. +- Provide native-specific layouts or partials when `turbo_native?` is true. +- Evaluate if any redirect logic needs `turbo_stream` fallbacks for native navigation. + +## Prototype Implementation Summary (Branch: `feature/hotwire-native-prototype`) +- **Turbo Native request detection**: Introduced a controller concern that inspects user agents and bridge headers, setting the `:turbo_native` variant for layouts and views. Helpers expose `turbo_native_app?` for conditional rendering. +- **Native-first layout**: Added `application.html+turbo_native.erb`, a slim layout that shares HTML chrome with the web experience while exporting navigation metadata and bridge hooks for the iOS/Android wrappers. +- **Navigation manifest**: Centralized navigation items in `ApplicationHelper` and expose a serialized payload used by both the layout variant and the Stimulus bridge so wrappers can render native tab bars consistently. +- **Bridge controller**: Added a `turbo_native_bridge` Stimulus controller that notifies native shells about navigation updates, listens for native visit requests, and dispatches lifecycle events for deeper integrations. +- **Authentication redirect**: Sign-out responses now set `Turbo-Visit-Control: reload` when handled by a native wrapper to force a clean session reset without breaking the PWA. + +## Native Wrapper Considerations +- Outline minimal iOS and Android wrapper requirements (Turbo Native libraries, authentication handshake, build tooling). +- Document environment variables or configuration the mobile shells must provide (base URL, client secrets, etc.). +- Plan to share session cookies between webview visits; confirm domain and TLS prerequisites. + +## Testing & QA Strategy +- Manual QA matrix covering web (PWA) and Turbo Native wrappers for the account overview flow. +- Automation exploration: evaluate if we can reuse PWA system tests with custom user agent to simulate Turbo Native. +- Define roll-back plan if native wrapper introduces regressions to PWA. + +## Open Questions +- Do we require offline caching beyond the existing service worker when running inside the native webview? +- How will biometric authentication hooks integrate with the current session management? +- What analytics adjustments are needed to differentiate native wrapper traffic? + +## Next Steps +1. Socialize this plan with stakeholders and collect feedback. +2. Once approved, cut `feature/hotwire-native-prototype` from `main`. +3. Execute the prototype tasks, tracking progress in the branch README or project board. **(In progress on `feature/hotwire-native-prototype`.)** +4. Iterate on documentation based on findings before merging back into `main`. **Document updates captured above; continue refining with mobile-team feedback.** diff --git a/test/controllers/turbo_native_layout_test.rb b/test/controllers/turbo_native_layout_test.rb new file mode 100644 index 00000000000..d28f485ef17 --- /dev/null +++ b/test/controllers/turbo_native_layout_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class TurboNativeLayoutTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + end + + test "applies turbo native layout for native user agents" do + sign_in @user + + get root_url, headers: { "HTTP_USER_AGENT" => "Turbo Native iOS" } + + assert_response :success + assert_select 'meta[name="turbo-native"][content="true"]' + assert_select '[data-controller="turbo-native-bridge"]' + end + + test "web layout remains unchanged for standard browsers" do + sign_in @user + + get root_url + + assert_response :success + assert_select 'meta[name="turbo-native"]', false + assert_select '[data-controller="turbo-native-bridge"]', false + end +end