ruby_everywhere 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/README.md +48 -0
  4. data/bridge/everywhere/bridge.js +388 -3
  5. data/bridge/everywhere/native.css +203 -0
  6. data/bridge/package.json +6 -3
  7. data/lib/everywhere/android_resources.rb +217 -0
  8. data/lib/everywhere/android_sdk.rb +330 -0
  9. data/lib/everywhere/asset_catalog.rb +185 -0
  10. data/lib/everywhere/auth_handoff.rb +398 -0
  11. data/lib/everywhere/auth_token.rb +118 -0
  12. data/lib/everywhere/builders/android.rb +844 -0
  13. data/lib/everywhere/builders/ios.rb +72 -3
  14. data/lib/everywhere/commands/build.rb +51 -9
  15. data/lib/everywhere/commands/clean.rb +10 -5
  16. data/lib/everywhere/commands/dev.rb +46 -8
  17. data/lib/everywhere/commands/doctor.rb +89 -3
  18. data/lib/everywhere/commands/install.rb +1 -1
  19. data/lib/everywhere/commands/logs.rb +117 -8
  20. data/lib/everywhere/config.rb +513 -14
  21. data/lib/everywhere/emulator.rb +405 -0
  22. data/lib/everywhere/engine.rb +21 -0
  23. data/lib/everywhere/icon.rb +196 -0
  24. data/lib/everywhere/log_filter.rb +74 -0
  25. data/lib/everywhere/mobile_config_endpoint.rb +13 -0
  26. data/lib/everywhere/mobile_configs_controller.rb +18 -0
  27. data/lib/everywhere/native_helper.rb +198 -5
  28. data/lib/everywhere/omniauth.rb +88 -0
  29. data/lib/everywhere/paths.rb +47 -0
  30. data/lib/everywhere/version.rb +1 -1
  31. data/support/mobile/android/README.md +217 -0
  32. data/support/mobile/android/app/build.gradle.kts +156 -0
  33. data/support/mobile/android/app/everywhere.properties +8 -0
  34. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  35. data/support/mobile/android/app/proguard-rules.pro +21 -0
  36. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  37. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  38. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  39. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  40. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  41. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  55. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  56. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  57. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  58. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  59. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  60. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  61. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  62. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  63. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  64. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  65. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  66. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  67. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  68. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  69. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  70. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  71. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  72. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  73. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  74. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  75. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  76. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  77. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  78. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  79. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  80. data/support/mobile/android/build.gradle.kts +8 -0
  81. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  82. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  83. data/support/mobile/android/gradle.properties +14 -0
  84. data/support/mobile/android/gradlew +251 -0
  85. data/support/mobile/android/gradlew.bat +94 -0
  86. data/support/mobile/android/settings.gradle.kts +28 -0
  87. data/support/mobile/ios/App/AppDelegate.swift +7 -1
  88. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  89. data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
  90. data/support/mobile/ios/App/EverywhereConfig.swift +105 -0
  91. data/support/mobile/ios/App/SceneDelegate.swift +144 -0
  92. data/support/mobile/ios/App.xcodeproj/project.pbxproj +8 -0
  93. data/support/mobile/ios/README.md +10 -0
  94. metadata +69 -1
@@ -16,6 +16,7 @@
16
16
  // Everywhere.on("menu", handler) // shell events; returns unsubscribe fn
17
17
  // Everywhere.visit("/settings") // Turbo.visit with location fallback
18
18
  // Everywhere.reloadTabs() // re-fetch native tab/nav config (mobile)
19
+ // Everywhere.auth.signIn("/auth/github") // provider sign-in in the system browser (mobile)
19
20
  //
20
21
  // Everywhere.haptics.impact("medium") // light | medium | heavy | soft | rigid
21
22
  // Everywhere.haptics.notification("success") // success | warning | error
@@ -103,6 +104,50 @@
103
104
  // notes is the markdown source; notesHtml is the same notes pre-rendered to
104
105
  // HTML (from the signed update feed — your own content), ready for a
105
106
  // changelog modal: el.innerHTML = notesHtml.
107
+ //
108
+ // Everywhere.menu({ title, message, items }) // Promise<item|null>
109
+ // items: [{ title, id?, style?, disabled? }] style: "destructive" | "cancel"
110
+ // Native action sheet in the mobile shell; a styled bottom sheet everywhere
111
+ // else. Resolves the chosen item (null on cancel/dismiss).
112
+ //
113
+ // Native chrome, declarative (no JS) — the bridge watches every page and,
114
+ // inside the mobile shell, lifts these into real native controls; in a
115
+ // browser (and the desktop shell) they stay as the plain HTML they already
116
+ // are, so the same markup works everywhere. Tapping a native control just
117
+ // .click()s the element it mirrors — a link navigates, a submit submits, a
118
+ // button fires its handler — so behavior is defined once, in the DOM.
119
+ //
120
+ // Nav bar button (right side by default):
121
+ // <a href="/notes/new" data-everywhere-nav-button
122
+ // data-everywhere-nav-title="New" data-everywhere-nav-icon="plus">New</a>
123
+ // Nav bar submit button (submits the form when tapped):
124
+ // <button type="submit" data-everywhere-nav-button
125
+ // data-everywhere-nav-title="Save" data-everywhere-nav-style="done">Save</button>
126
+ // Nav bar pull-down / overflow menu (defaults to the ⋯ ellipsis icon):
127
+ // <div data-everywhere-nav-menu data-everywhere-nav-title="More">
128
+ // <a href="/share" data-everywhere-menu-item data-everywhere-menu-title="Share"
129
+ // data-everywhere-menu-icon-ios="square.and.arrow.up"
130
+ // data-everywhere-menu-icon-android="share">Share</a>
131
+ // <button form="delete_1" data-everywhere-menu-item data-everywhere-menu-title="Delete"
132
+ // data-everywhere-menu-style="destructive">Delete</button>
133
+ // </div>
134
+ // In-content action sheet (a trigger + the items it opens):
135
+ // <div data-everywhere-menu>
136
+ // <button data-everywhere-menu-trigger>Options</button>
137
+ // <div data-everywhere-menu-items>
138
+ // <a href="/x/edit" data-everywhere-menu-item>Edit</a>
139
+ // <button form="del" data-everywhere-menu-item
140
+ // data-everywhere-menu-style="destructive">Delete</button>
141
+ // </div>
142
+ // </div>
143
+ // Icons are per-platform, like tabs: -icon-ios is an SF Symbol, -icon-android
144
+ // a Material name, and a plain -icon is the shared fallback; the bridge picks
145
+ // by the running os, so one page serves both shells. Icons are ignored on the
146
+ // web. side is "left" or "right" (default). Nav-bar sources are hidden in the
147
+ // shell (they live in the bar now) but shown in a browser; the action-sheet
148
+ // trigger toggles its items as an inline menu when there's no native shell.
149
+ // These are the everywhere_nav_button / everywhere_nav_menu /
150
+ // everywhere_submit_button / everywhere_menu Rails helpers.
106
151
 
107
152
  const config =
108
153
  (typeof window !== "undefined" && window.__EVERYWHERE_CONFIG__) || {}
@@ -395,6 +440,7 @@ const HAPTICS_COMPONENT = "everywhere--haptics"
395
440
  const PERMISSIONS_COMPONENT = "everywhere--permissions"
396
441
  const BIOMETRICS_COMPONENT = "everywhere--biometrics"
397
442
  const STORAGE_COMPONENT = "everywhere--storage"
443
+ const MENU_COMPONENT = "everywhere--menu"
398
444
 
399
445
  function installWebBridge() {
400
446
  if (window.HotwireNative) return
@@ -487,8 +533,9 @@ const mobile = {
487
533
  // Ask the shell to re-fetch its native navigation config (tabs + path
488
534
  // rules) now, instead of waiting for the next app foreground. Call it after
489
535
  // a change that affects which tabs should show — signing in or out — so the
490
- // tab bar updates immediately. Uses a dedicated WKScriptMessageHandler, so
491
- // it doesn't depend on a bridge component being mounted on the page.
536
+ // tab bar updates immediately. Uses the shell's control channel (a dedicated
537
+ // WKScriptMessageHandler on iOS, a WebMessageListener on Android), so it
538
+ // doesn't depend on a bridge component being mounted on the page.
492
539
  reloadTabs() {
493
540
  const control = nativeControlChannel()
494
541
  if (control) {
@@ -521,6 +568,16 @@ const mobile = {
521
568
  if (control) control.postMessage({ action: "clearInstance", ...(to ? { to } : {}) })
522
569
  },
523
570
 
571
+ // Open a provider sign-in in the system browser rather than this web view —
572
+ // where Google refuses to run at all and the rest lose the password manager
573
+ // and the user's existing session. The shell takes it from here: auth
574
+ // session, one-time handoff token, full reset onto the signed-in app.
575
+ authSignIn(path) {
576
+ const control = nativeControlChannel()
577
+ if (control) control.postMessage({ action: "authFlow", to: path })
578
+ else window.location.assign(path)
579
+ },
580
+
524
581
  // Real haptics via the shell's "everywhere--haptics" component; vibration
525
582
  // fallback while the handshake completes or on an older shell.
526
583
  haptic(kind, value) {
@@ -633,13 +690,67 @@ const mobile = {
633
690
  }
634
691
  })
635
692
  })
693
+ },
694
+
695
+ // Native chrome (nav bar items + action sheets) via the shell's
696
+ // "everywhere--menu" component. Unlike the other components these replies are
697
+ // not one-shot: native re-replies to the SAME message on every tap, so the
698
+ // caller owns the callback's lifetime (menuRemove) rather than clearing it on
699
+ // first reply.
700
+ menuSupported() {
701
+ const web = window.HotwireNative && window.HotwireNative.web
702
+ return !!(web && web.supportsComponent(MENU_COMPONENT))
703
+ },
704
+
705
+ menuSend(event, data, callback) {
706
+ const web = window.HotwireNative && window.HotwireNative.web
707
+ if (!web || !web.supportsComponent(MENU_COMPONENT)) return null
708
+ return web.send({ component: MENU_COMPONENT, event, data, callback })
709
+ },
710
+
711
+ menuRemove(id) {
712
+ const web = window.HotwireNative && window.HotwireNative.web
713
+ if (web && id !== null && id !== undefined && web.removeCallbackFor) web.removeCallbackFor(id)
636
714
  }
637
715
  }
638
716
 
717
+ // The shell's out-of-band command line: app-level actions (reset, tabs,
718
+ // badges, instance, auth) that must work on any page, whether or not a bridge
719
+ // component happens to be mounted on it. Each shell injects it its own way,
720
+ // and the two APIs disagree about what a message is:
721
+ //
722
+ // iOS — WKUserContentController.add(_:name:) hands us
723
+ // window.webkit.messageHandlers.everywhereControl, whose
724
+ // postMessage takes an *object* and serializes it across the
725
+ // boundary for us.
726
+ // Android — WebViewCompat.addWebMessageListener("everywhereControl") hands us
727
+ // window.everywhereControl, backed by a WebMessagePort, and a web
728
+ // message is *text*: postMessage(String). (Android has no user
729
+ // scripts either, so the shell injects this with
730
+ // addDocumentStartJavaScript — it's in place before page JS runs.)
731
+ //
732
+ // Normalizing here rather than at each call site is the point: the seven
733
+ // callers below all speak the object shape, and only this function knows which
734
+ // shell is listening. iOS gets its handler back untouched — it already is the
735
+ // normalized shape, so that path stays byte-for-byte what it was.
639
736
  function nativeControlChannel() {
640
- return window.webkit &&
737
+ if (typeof window === "undefined") return null
738
+
739
+ const webkit =
740
+ window.webkit &&
641
741
  window.webkit.messageHandlers &&
642
742
  window.webkit.messageHandlers.everywhereControl
743
+ if (webkit) return webkit
744
+
745
+ const android = window.everywhereControl
746
+ if (android && typeof android.postMessage === "function") {
747
+ // Built per call, not cached: a fresh document (a Turbo drive visit is
748
+ // not, but a real navigation is) gets a fresh injected object, and there's
749
+ // nothing here worth keeping alive across one.
750
+ return { postMessage: (message) => android.postMessage(JSON.stringify(message)) }
751
+ }
752
+
753
+ return null
643
754
  }
644
755
 
645
756
  // --- public surface ----------------------------------------------------------
@@ -689,6 +800,26 @@ export const Everywhere = {
689
800
  else window.location.assign(path)
690
801
  },
691
802
 
803
+ // A native action sheet in the mobile shell; a styled bottom sheet in the
804
+ // browser and desktop shell. Resolves the chosen item (the same object you
805
+ // passed), or null on cancel/dismiss:
806
+ //
807
+ // const choice = await Everywhere.menu({
808
+ // title: "Post",
809
+ // items: [{ id: "share", title: "Share" },
810
+ // { id: "delete", title: "Delete", style: "destructive" }]
811
+ // })
812
+ // if (choice?.id === "delete") …
813
+ menu(options = {}) {
814
+ const items = Array.isArray(options.items) ? options.items : []
815
+ return presentMenu({
816
+ title: options.title,
817
+ message: options.message,
818
+ items,
819
+ source: options.source
820
+ }).then((id) => (id == null ? null : items.find((item) => String(item.id) === String(id)) || null))
821
+ },
822
+
692
823
  // Refresh the native tab bar / navigation config now (mobile only; a no-op
693
824
  // in the browser and desktop shell, where there's nothing to refresh).
694
825
  reloadTabs() {
@@ -704,6 +835,18 @@ export const Everywhere = {
704
835
  else this.visit(to || window.location.pathname)
705
836
  },
706
837
 
838
+ // Third-party sign-in (everywhere.yml `auth:`). You rarely need this: the
839
+ // shell already diverts the paths listed there, so a plain link to
840
+ // /auth/github does the right thing everywhere. Call it when the sign-in is
841
+ // driven by something the shell can't see as a visit — a fetch, a custom
842
+ // element, a button that has other work to do first.
843
+ auth: {
844
+ signIn(path) {
845
+ if (adapter.authSignIn) adapter.authSignIn(path)
846
+ else window.location.assign(path)
847
+ }
848
+ },
849
+
707
850
  clipboard: {
708
851
  write(text) {
709
852
  return adapter.clipboardWrite(text)
@@ -1032,10 +1175,237 @@ async function wireBiometricToggles() {
1032
1175
  })
1033
1176
  }
1034
1177
 
1178
+ // --- native chrome (menus, nav bar items) -----------------------------------
1179
+ // Picks the native action sheet in the mobile shell and a DOM bottom sheet
1180
+ // (native.css .everywhere-sheet) everywhere else. Resolves the chosen item's
1181
+ // id, or null on cancel/dismiss.
1182
+ function presentMenu({ title, message, items, source }) {
1183
+ const list = (items || []).map((item, i) => ({
1184
+ id: item.id != null ? String(item.id) : String(i),
1185
+ title: item.title || "",
1186
+ image: item.image || null,
1187
+ style: item.style || "default",
1188
+ disabled: !!item.disabled
1189
+ }))
1190
+
1191
+ if (platform === "mobile" && adapter.menuSupported && adapter.menuSupported()) {
1192
+ return new Promise((resolve) => {
1193
+ let settled = false
1194
+ const id = adapter.menuSend("menu", { title, message, items: list, source }, (message) => {
1195
+ if (settled) return
1196
+ settled = true
1197
+ adapter.menuRemove(id)
1198
+ const data = (message && message.data) || {}
1199
+ resolve(data.action === "select" && data.id != null ? String(data.id) : null)
1200
+ })
1201
+ if (id === null) resolve(domMenu({ title, message, items: list }))
1202
+ })
1203
+ }
1204
+ return domMenu({ title, message, items: list })
1205
+ }
1206
+
1207
+ // The honest web/desktop fallback for Everywhere.menu(): a modal bottom sheet
1208
+ // built from the same item list. No inline scripts — pure DOM — so it clears CSP.
1209
+ function domMenu({ title, message, items }) {
1210
+ if (typeof document === "undefined") return Promise.resolve(null)
1211
+
1212
+ return new Promise((resolve) => {
1213
+ const overlay = document.createElement("div")
1214
+ overlay.className = "everywhere-sheet-overlay"
1215
+ const sheet = document.createElement("div")
1216
+ sheet.className = "everywhere-sheet"
1217
+ sheet.setAttribute("role", "menu")
1218
+
1219
+ if (title || message) {
1220
+ const header = document.createElement("div")
1221
+ header.className = "everywhere-sheet-header"
1222
+ if (title) {
1223
+ const el = document.createElement("div")
1224
+ el.className = "everywhere-sheet-title"
1225
+ el.textContent = title
1226
+ header.appendChild(el)
1227
+ }
1228
+ if (message) {
1229
+ const el = document.createElement("div")
1230
+ el.className = "everywhere-sheet-message"
1231
+ el.textContent = message
1232
+ header.appendChild(el)
1233
+ }
1234
+ sheet.appendChild(header)
1235
+ }
1236
+
1237
+ let done = false
1238
+ const close = (value) => {
1239
+ if (done) return
1240
+ done = true
1241
+ document.removeEventListener("keydown", onKey)
1242
+ overlay.remove()
1243
+ resolve(value)
1244
+ }
1245
+ const onKey = (event) => { if (event.key === "Escape") close(null) }
1246
+
1247
+ items.forEach((item) => {
1248
+ const button = document.createElement("button")
1249
+ button.type = "button"
1250
+ button.className = "everywhere-sheet-item" +
1251
+ (item.style === "destructive" ? " everywhere-sheet-item-destructive" : "")
1252
+ button.textContent = item.title
1253
+ button.setAttribute("role", "menuitem")
1254
+ if (item.disabled) button.disabled = true
1255
+ else button.addEventListener("click", () => close(item.id))
1256
+ sheet.appendChild(button)
1257
+ })
1258
+
1259
+ const cancel = document.createElement("button")
1260
+ cancel.type = "button"
1261
+ cancel.className = "everywhere-sheet-item everywhere-sheet-cancel"
1262
+ cancel.textContent = "Cancel"
1263
+ cancel.addEventListener("click", () => close(null))
1264
+ sheet.appendChild(cancel)
1265
+
1266
+ overlay.addEventListener("click", (event) => { if (event.target === overlay) close(null) })
1267
+ document.addEventListener("keydown", onKey)
1268
+ overlay.appendChild(sheet)
1269
+ document.body.appendChild(overlay)
1270
+ })
1271
+ }
1272
+
1273
+ // Per-platform icon, resolved like tabs: `<base>-<os>` (SF Symbol on iOS, a
1274
+ // Material name on Android) then the shared `<base>`. One page serves both shells.
1275
+ function resolveNativeIcon(el, base) {
1276
+ return el.getAttribute(`${base}-${os}`) || el.getAttribute(base) || null
1277
+ }
1278
+
1279
+ function navText(el) {
1280
+ return (el.getAttribute("data-everywhere-nav-title") || el.textContent || "").trim()
1281
+ }
1282
+
1283
+ function navSide(el) {
1284
+ return el.getAttribute("data-everywhere-nav-side") === "left" ? "left" : "right"
1285
+ }
1286
+
1287
+ function menuItemTitle(el) {
1288
+ return (el.getAttribute("data-everywhere-menu-title") || el.textContent || "").trim()
1289
+ }
1290
+
1291
+ // The nav bar items the current page declares, plus a fresh id→element map so
1292
+ // native taps can .click() the source element. Menu children get ids like
1293
+ // "n2.0" so a pull-down item routes back to the right element.
1294
+ let navMessageId = null
1295
+ const navElements = new Map()
1296
+
1297
+ function collectNavItems() {
1298
+ navElements.clear()
1299
+ const items = []
1300
+ let n = 0
1301
+
1302
+ document.querySelectorAll("[data-everywhere-nav-button]").forEach((el) => {
1303
+ const id = "n" + (n++)
1304
+ navElements.set(id, el)
1305
+ items.push({
1306
+ id,
1307
+ kind: "button",
1308
+ title: navText(el),
1309
+ image: resolveNativeIcon(el, "data-everywhere-nav-icon"),
1310
+ side: navSide(el),
1311
+ style: el.getAttribute("data-everywhere-nav-style") || "plain",
1312
+ disabled: el.disabled === true || el.hasAttribute("data-everywhere-nav-disabled")
1313
+ })
1314
+ })
1315
+
1316
+ document.querySelectorAll("[data-everywhere-nav-menu]").forEach((menuEl) => {
1317
+ const id = "n" + (n++)
1318
+ const children = []
1319
+ menuEl.querySelectorAll("[data-everywhere-menu-item]").forEach((child, i) => {
1320
+ const cid = id + "." + i
1321
+ navElements.set(cid, child)
1322
+ children.push({
1323
+ id: cid,
1324
+ title: menuItemTitle(child),
1325
+ image: resolveNativeIcon(child, "data-everywhere-menu-icon"),
1326
+ style: child.getAttribute("data-everywhere-menu-style") || "default"
1327
+ })
1328
+ })
1329
+ items.push({
1330
+ id,
1331
+ kind: "menu",
1332
+ title: navText(menuEl),
1333
+ image: resolveNativeIcon(menuEl, "data-everywhere-nav-icon") || "ellipsis.circle",
1334
+ side: navSide(menuEl),
1335
+ children
1336
+ })
1337
+ })
1338
+
1339
+ return items
1340
+ }
1341
+
1342
+ // Send this page's nav bar items to the shell. Native re-replies on every tap,
1343
+ // so the callback lives until the next page swaps it out (menuRemove first).
1344
+ function applyNavItems() {
1345
+ if (platform !== "mobile" || !adapter.menuSupported || !adapter.menuSupported()) return
1346
+ if (navMessageId !== null) {
1347
+ adapter.menuRemove(navMessageId)
1348
+ navMessageId = null
1349
+ }
1350
+ const items = collectNavItems()
1351
+ if (!items.length) return
1352
+
1353
+ navMessageId = adapter.menuSend("navItems", { items }, (message) => {
1354
+ const data = (message && message.data) || {}
1355
+ if (data.action === "tap" && data.id != null) {
1356
+ const el = navElements.get(String(data.id))
1357
+ if (el) el.click()
1358
+ }
1359
+ })
1360
+ }
1361
+
1362
+ // A tap on an in-content [data-everywhere-menu-trigger]: a native action sheet
1363
+ // in the shell, an inline disclosure (toggling [data-everywhere-menu-open]) in
1364
+ // a browser. Either way, choosing an item .click()s the element it maps to.
1365
+ function handleMenuTrigger(event, trigger) {
1366
+ const container = trigger.closest("[data-everywhere-menu]") || trigger.parentElement
1367
+ if (!container) return
1368
+ const holder = container.querySelector("[data-everywhere-menu-items]") || container
1369
+ const elements = Array.from(holder.querySelectorAll("[data-everywhere-menu-item]"))
1370
+
1371
+ if (platform === "mobile" && adapter.menuSupported && adapter.menuSupported()) {
1372
+ event.preventDefault()
1373
+ const rect = trigger.getBoundingClientRect()
1374
+ const items = elements.map((el, i) => ({
1375
+ id: String(i),
1376
+ title: menuItemTitle(el),
1377
+ image: resolveNativeIcon(el, "data-everywhere-menu-icon"),
1378
+ style: el.getAttribute("data-everywhere-menu-style") || "default",
1379
+ disabled: el.disabled === true
1380
+ }))
1381
+ presentMenu({
1382
+ title: trigger.getAttribute("data-everywhere-menu-title") ||
1383
+ container.getAttribute("data-everywhere-menu-title") || null,
1384
+ items,
1385
+ source: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }
1386
+ }).then((id) => {
1387
+ if (id == null) return
1388
+ const el = elements[parseInt(id, 10)]
1389
+ if (el) el.click()
1390
+ })
1391
+ } else {
1392
+ event.preventDefault()
1393
+ const open = container.hasAttribute("data-everywhere-menu-open")
1394
+ closeInlineMenus()
1395
+ if (!open) container.setAttribute("data-everywhere-menu-open", "")
1396
+ }
1397
+ }
1398
+
1399
+ function closeInlineMenus() {
1400
+ document.querySelectorAll("[data-everywhere-menu-open]")
1401
+ .forEach((el) => el.removeAttribute("data-everywhere-menu-open"))
1402
+ }
1403
+
1035
1404
  function applyDeclarative() {
1036
1405
  applyBadgeMetas()
1037
1406
  applyBiometricGates()
1038
1407
  wireBiometricToggles()
1408
+ applyNavItems()
1039
1409
  }
1040
1410
 
1041
1411
  if (typeof document !== "undefined") {
@@ -1061,6 +1431,13 @@ if (typeof document !== "undefined") {
1061
1431
  if (content) content.hidden = true
1062
1432
  if (overlay) overlay.hidden = true
1063
1433
  })
1434
+ // Never snapshot an open inline menu; the next page's applyNavItems()
1435
+ // replaces the nav callback, so drop this page's before it's cached.
1436
+ closeInlineMenus()
1437
+ if (navMessageId !== null) {
1438
+ adapter.menuRemove(navMessageId)
1439
+ navMessageId = null
1440
+ }
1064
1441
  })
1065
1442
 
1066
1443
  document.addEventListener("click", (event) => {
@@ -1071,6 +1448,14 @@ if (typeof document !== "undefined") {
1071
1448
  const unlock = event.target.closest("[data-everywhere-biometric-unlock]")
1072
1449
  const gate = unlock && unlock.closest("[data-everywhere-biometric-lock]")
1073
1450
  if (gate) resolveBiometricGate(gate, { prompt: true })
1451
+
1452
+ const trigger = event.target.closest("[data-everywhere-menu-trigger]")
1453
+ if (trigger) {
1454
+ handleMenuTrigger(event, trigger)
1455
+ } else if (!event.target.closest("[data-everywhere-menu-open]")) {
1456
+ // A click anywhere outside an open inline menu dismisses it.
1457
+ closeInlineMenus()
1458
+ }
1074
1459
  })
1075
1460
 
1076
1461
  document.addEventListener("change", async (event) => {
@@ -0,0 +1,203 @@
1
+ /* @rubyeverywhere/bridge — safe-area utilities for the native shells.
2
+ *
3
+ * Pair with <meta name="viewport" content="…, viewport-fit=cover"> so the
4
+ * browser exposes the device's safe-area insets. These classes pad content
5
+ * clear of the status bar / Dynamic Island / home indicator without
6
+ * hardcoding device sizes. On the desktop shell and plain browsers the insets
7
+ * are 0, so the classes are inert — safe to apply unconditionally.
8
+ *
9
+ * Include in Rails: <%= stylesheet_link_tag "everywhere/native" %>
10
+ * (Sinatra/Hanami: it's vendored to public/ by `every install`.)
11
+ */
12
+
13
+ :root {
14
+ --everywhere-inset-top: env(safe-area-inset-top, 0px);
15
+ --everywhere-inset-right: env(safe-area-inset-right, 0px);
16
+ --everywhere-inset-bottom: env(safe-area-inset-bottom, 0px);
17
+ --everywhere-inset-left: env(safe-area-inset-left, 0px);
18
+ }
19
+
20
+ /* Padding utilities — add safe-area room on top of any existing padding. */
21
+ .native-inset {
22
+ padding-top: calc(var(--everywhere-inset-top) + var(--native-inset-pad, 0px));
23
+ padding-bottom: calc(var(--everywhere-inset-bottom) + var(--native-inset-pad, 0px));
24
+ }
25
+ .native-inset-top { padding-top: calc(var(--everywhere-inset-top) + var(--native-inset-pad, 0px)); }
26
+ .native-inset-bottom { padding-bottom: calc(var(--everywhere-inset-bottom) + var(--native-inset-pad, 0px)); }
27
+ .native-inset-left { padding-left: calc(var(--everywhere-inset-left) + var(--native-inset-pad, 0px)); }
28
+ .native-inset-right { padding-right: calc(var(--everywhere-inset-right) + var(--native-inset-pad, 0px)); }
29
+
30
+ /* Margin variants, when padding would fight an existing background. */
31
+ .native-inset-mt { margin-top: var(--everywhere-inset-top); }
32
+ .native-inset-mb { margin-bottom: var(--everywhere-inset-bottom); }
33
+
34
+ /* A bar pinned to the bottom edge that stays above the home indicator. */
35
+ .native-safe-bottom {
36
+ padding-bottom: max(var(--everywhere-inset-bottom), var(--native-safe-min, 0.5rem));
37
+ }
38
+
39
+ /* Fixed/absolute elements anchored near the bottom (toasts, nudges, FABs):
40
+ * lifts them clear of the home indicator AND anything overlaying the webview
41
+ * from below — on iOS the floating tab bar contributes to the bottom inset.
42
+ * --native-bottom-gap is the visual gap above that (default 1rem). */
43
+ .native-bottom {
44
+ bottom: calc(var(--everywhere-inset-bottom) + var(--native-bottom-gap, 1rem));
45
+ }
46
+
47
+ /* Full-height element that respects both vertical insets. */
48
+ .native-safe-height {
49
+ min-height: calc(100dvh - var(--everywhere-inset-top) - var(--everywhere-inset-bottom));
50
+ }
51
+
52
+ /* ---------------------------------------------------------------------------
53
+ * Native chrome — styling for the everywhere_fab / everywhere_menu helpers and
54
+ * the bridge's DOM action-sheet fallback. Set --everywhere-tint in your own CSS
55
+ * to brand the accent (it defaults to the platform accent color).
56
+ * ------------------------------------------------------------------------- */
57
+
58
+ :root {
59
+ --everywhere-tint: AccentColor;
60
+ }
61
+
62
+ /* Floating action button (everywhere_fab): fixed, safe-area-aware, above the
63
+ * home indicator and iOS floating tab bar. A real link/button, so it shows in
64
+ * a browser too. */
65
+ .everywhere-fab {
66
+ position: fixed;
67
+ right: max(var(--everywhere-inset-right), 1rem);
68
+ bottom: calc(var(--everywhere-inset-bottom) + var(--native-bottom-gap, 1rem));
69
+ z-index: 1000;
70
+ display: inline-flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ gap: 0.5rem;
74
+ width: 3.5rem;
75
+ height: 3.5rem;
76
+ padding: 0;
77
+ border: 0;
78
+ border-radius: 9999px;
79
+ background: var(--everywhere-fab-bg, var(--everywhere-tint, #1a73e8));
80
+ color: var(--everywhere-fab-color, #fff);
81
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.28);
82
+ cursor: pointer;
83
+ -webkit-tap-highlight-color: transparent;
84
+ transition: transform 0.1s ease, box-shadow 0.1s ease;
85
+ }
86
+ .everywhere-fab:active {
87
+ transform: scale(0.94);
88
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3);
89
+ }
90
+ .everywhere-fab-left {
91
+ right: auto;
92
+ left: max(var(--everywhere-inset-left), 1rem);
93
+ }
94
+ /* Extended FAB: pill with a visible label beside the icon. */
95
+ .everywhere-fab-extended {
96
+ width: auto;
97
+ height: 3.25rem;
98
+ padding: 0 1.25rem;
99
+ font: inherit;
100
+ font-weight: 600;
101
+ }
102
+ .everywhere-fab-icon {
103
+ width: 1.5rem;
104
+ height: 1.5rem;
105
+ flex: none;
106
+ }
107
+ .everywhere-fab-label {
108
+ white-space: nowrap;
109
+ }
110
+
111
+ /* In-content action sheet (everywhere_menu) inline fallback: the items stay
112
+ * hidden until the trigger opens them. In the native shell the bridge shows a
113
+ * real action sheet instead and these never appear. */
114
+ [data-everywhere-menu-items] {
115
+ display: none;
116
+ }
117
+ [data-everywhere-menu-open] > [data-everywhere-menu-items],
118
+ [data-everywhere-menu-open] [data-everywhere-menu-items] {
119
+ display: block;
120
+ }
121
+
122
+ /* Nav-bar sources (everywhere_nav_button / everywhere_nav_menu) live in the
123
+ * top bar inside the shell, so hide the in-page copy there — but keep it in a
124
+ * browser, where there is no native bar. Keyed on the bridge's platform marker
125
+ * (set only by the mobile web bridge). */
126
+ html[data-bridge-platform] [data-everywhere-nav-button],
127
+ html[data-bridge-platform] [data-everywhere-nav-menu] {
128
+ display: none !important;
129
+ }
130
+
131
+ /* DOM action-sheet fallback rendered by the bridge (Everywhere.menu / the
132
+ * everywhere_menu trigger) when there is no native shell. */
133
+ .everywhere-sheet-overlay {
134
+ position: fixed;
135
+ inset: 0;
136
+ z-index: 2000;
137
+ display: flex;
138
+ align-items: flex-end;
139
+ justify-content: center;
140
+ padding: 0.75rem;
141
+ padding-bottom: calc(var(--everywhere-inset-bottom) + 0.75rem);
142
+ background: rgba(0, 0, 0, 0.4);
143
+ animation: everywhere-sheet-fade 0.15s ease;
144
+ }
145
+ .everywhere-sheet {
146
+ width: 100%;
147
+ max-width: 30rem;
148
+ display: flex;
149
+ flex-direction: column;
150
+ gap: 1px;
151
+ border-radius: 0.9rem;
152
+ overflow: hidden;
153
+ background: rgba(120, 120, 128, 0.2);
154
+ animation: everywhere-sheet-rise 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
155
+ }
156
+ .everywhere-sheet-header {
157
+ padding: 0.85rem 1rem;
158
+ text-align: center;
159
+ background: Canvas;
160
+ color: color-mix(in srgb, CanvasText 60%, transparent);
161
+ }
162
+ .everywhere-sheet-title {
163
+ font-weight: 600;
164
+ color: CanvasText;
165
+ }
166
+ .everywhere-sheet-message {
167
+ font-size: 0.85rem;
168
+ margin-top: 0.15rem;
169
+ }
170
+ .everywhere-sheet-item {
171
+ appearance: none;
172
+ border: 0;
173
+ width: 100%;
174
+ padding: 1rem;
175
+ font: inherit;
176
+ font-size: 1.05rem;
177
+ text-align: center;
178
+ background: Canvas;
179
+ color: var(--everywhere-tint, #1a73e8);
180
+ cursor: pointer;
181
+ }
182
+ .everywhere-sheet-item:active {
183
+ background: color-mix(in srgb, CanvasText 8%, Canvas);
184
+ }
185
+ .everywhere-sheet-item:disabled {
186
+ color: color-mix(in srgb, CanvasText 30%, transparent);
187
+ cursor: default;
188
+ }
189
+ .everywhere-sheet-item-destructive {
190
+ color: #e5484d;
191
+ }
192
+ .everywhere-sheet-cancel {
193
+ margin-top: 0.5rem;
194
+ border-radius: 0.9rem;
195
+ font-weight: 600;
196
+ color: CanvasText;
197
+ }
198
+ @keyframes everywhere-sheet-fade {
199
+ from { opacity: 0; }
200
+ }
201
+ @keyframes everywhere-sheet-rise {
202
+ from { transform: translateY(1rem); opacity: 0; }
203
+ }