@acms-subir/core 0.3.3-next.20260929113209 → 0.3.3

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 (251) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/sections.mjs +186 -0
  9. package/cli/themes.mjs +52 -8
  10. package/docs/mobile.md +527 -0
  11. package/docs/sections.md +203 -0
  12. package/docs/theme-authoring.md +1 -1
  13. package/integration.mjs +55 -1
  14. package/mobile-template/client/AGENTS.md +97 -0
  15. package/mobile-template/client/README.md +65 -0
  16. package/mobile-template/client/app/_layout.tsx +56 -0
  17. package/mobile-template/client/app/index.tsx +21 -0
  18. package/mobile-template/client/app.json +21 -0
  19. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  20. package/mobile-template/client/eas.json +21 -0
  21. package/mobile-template/client/env.example +3 -0
  22. package/mobile-template/client/gitignore +42 -0
  23. package/mobile-template/client/package.json +38 -0
  24. package/mobile-template/client/sections/index.ts +11 -0
  25. package/mobile-template/client/tsconfig.json +7 -0
  26. package/mobile-template/editor/AGENTS.md +66 -0
  27. package/mobile-template/editor/README.md +73 -0
  28. package/mobile-template/editor/app/_layout.tsx +76 -0
  29. package/mobile-template/editor/app/auth.tsx +13 -0
  30. package/mobile-template/editor/app/index.tsx +55 -0
  31. package/mobile-template/editor/app.json +21 -0
  32. package/mobile-template/editor/components/Screen.tsx +51 -0
  33. package/mobile-template/editor/components/SignIn.tsx +36 -0
  34. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  35. package/mobile-template/editor/eas.json +21 -0
  36. package/mobile-template/editor/env.example +5 -0
  37. package/mobile-template/editor/gitignore +42 -0
  38. package/mobile-template/editor/lib/appearance.tsx +12 -0
  39. package/mobile-template/editor/lib/config.ts +17 -0
  40. package/mobile-template/editor/lib/format.ts +24 -0
  41. package/mobile-template/editor/lib/tools.ts +16 -0
  42. package/mobile-template/editor/package.json +40 -0
  43. package/mobile-template/editor/tools/README.md +2 -0
  44. package/mobile-template/editor/tsconfig.json +7 -0
  45. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  46. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  47. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  48. package/mobile-template/recipes/booking/recipe.json +17 -0
  49. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  50. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  51. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  52. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  54. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  55. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  56. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  57. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  58. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  59. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  60. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  62. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  63. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  64. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  65. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  67. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  68. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  69. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  70. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  72. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  73. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  74. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  75. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  76. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  77. package/mobile-template/recipes/events/recipe.json +15 -0
  78. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  79. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  80. package/mobile-template/recipes/news/recipe.json +13 -0
  81. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  82. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  83. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  84. package/mobile-template/recipes/push/recipe.json +20 -0
  85. package/package.json +4 -2
  86. package/section-library/README.md +16 -0
  87. package/section-library/contact/Contact.astro +49 -0
  88. package/section-library/contact/section.json +106 -0
  89. package/section-library/cta-band/CtaBand.astro +28 -0
  90. package/section-library/cta-band/section.json +51 -0
  91. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  92. package/section-library/feature-grid/section.json +107 -0
  93. package/section-library/gallery/Gallery.astro +29 -0
  94. package/section-library/gallery/section.json +51 -0
  95. package/section-library/hero-banner/HeroBanner.astro +42 -0
  96. package/section-library/hero-banner/section.json +81 -0
  97. package/section-library/latest-posts/LatestPosts.astro +22 -0
  98. package/section-library/latest-posts/section.json +37 -0
  99. package/section-library/pricing/Pricing.astro +40 -0
  100. package/section-library/pricing/section.json +117 -0
  101. package/section-library/questions/Questions.astro +38 -0
  102. package/section-library/questions/section.json +69 -0
  103. package/section-library/service-times/ServiceTimes.astro +42 -0
  104. package/section-library/service-times/section.json +127 -0
  105. package/section-library/team/Team.astro +45 -0
  106. package/section-library/team/section.json +88 -0
  107. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  108. package/section-library/upcoming-events/section.json +51 -0
  109. package/src/actions/index.ts +19 -171
  110. package/src/components/AiSettings.tsx +45 -5
  111. package/src/components/DesignManager.tsx +112 -16
  112. package/src/components/MobileApp.tsx +392 -0
  113. package/src/components/ThemeSections.astro +40 -21
  114. package/src/components/ThemeSectionsEditor.tsx +757 -229
  115. package/src/components/editor/PostEditor.tsx +174 -10
  116. package/src/components/import/ImportExport.tsx +4 -1
  117. package/src/components/import/runner.ts +1 -0
  118. package/src/components/sections/SectionFields.tsx +560 -0
  119. package/src/components/sections/SectionPicker.tsx +120 -0
  120. package/src/components/sections/TranslationPanel.tsx +143 -0
  121. package/src/components/sections/checks.ts +64 -0
  122. package/src/layouts/Admin.astro +6 -1
  123. package/src/layouts/Site.astro +107 -19
  124. package/src/lib/agent-discovery.ts +11 -7
  125. package/src/lib/agent.ts +35 -2
  126. package/src/lib/ai-schema.ts +5 -0
  127. package/src/lib/ai.ts +5 -0
  128. package/src/lib/api.ts +12 -0
  129. package/src/lib/content-service.ts +198 -0
  130. package/src/lib/design-service.ts +153 -5
  131. package/src/lib/drafts.ts +3 -3
  132. package/src/lib/editor/auth.ts +264 -0
  133. package/src/lib/editor/bookings.ts +60 -0
  134. package/src/lib/editor/config.ts +25 -0
  135. package/src/lib/editor/documents.ts +227 -0
  136. package/src/lib/editor/http.ts +48 -0
  137. package/src/lib/editor/me.ts +30 -0
  138. package/src/lib/editor/migration.ts +57 -0
  139. package/src/lib/editor/next.ts +11 -0
  140. package/src/lib/editor/openapi.ts +124 -0
  141. package/src/lib/editor/schemas.ts +91 -0
  142. package/src/lib/editor/sections.ts +96 -0
  143. package/src/lib/editor/store.ts +184 -0
  144. package/src/lib/editor/token-route.ts +74 -0
  145. package/src/lib/editor/translations.ts +163 -0
  146. package/src/lib/email/accounts.ts +2 -1
  147. package/src/lib/email/types.ts +2 -1
  148. package/src/lib/home-source.ts +34 -0
  149. package/src/lib/i18n.ts +34 -0
  150. package/src/lib/import/apply.ts +31 -2
  151. package/src/lib/import/bundle.ts +4 -0
  152. package/src/lib/import/export.ts +1 -0
  153. package/src/lib/import/ghost.ts +1 -0
  154. package/src/lib/media/upload.ts +83 -0
  155. package/src/lib/mobile/associations.ts +25 -0
  156. package/src/lib/mobile/calendar.ts +19 -11
  157. package/src/lib/mobile/config.mjs +59 -0
  158. package/src/lib/mobile/content.ts +18 -13
  159. package/src/lib/mobile/embed.ts +83 -0
  160. package/src/lib/mobile/http.ts +8 -6
  161. package/src/lib/mobile/locale.ts +18 -0
  162. package/src/lib/mobile/openapi.ts +111 -0
  163. package/src/lib/mobile/pages.ts +71 -0
  164. package/src/lib/mobile/route.ts +3 -3
  165. package/src/lib/mobile/schemas.ts +60 -0
  166. package/src/lib/mobile/site.ts +41 -0
  167. package/src/lib/push/api.ts +158 -0
  168. package/src/lib/push/config.ts +20 -0
  169. package/src/lib/push/expo.ts +76 -0
  170. package/src/lib/push/migration.ts +60 -0
  171. package/src/lib/push/service.ts +302 -0
  172. package/src/lib/push/store.ts +144 -0
  173. package/src/lib/push/types.ts +43 -0
  174. package/src/lib/repo/migrations.ts +11 -0
  175. package/src/lib/repo/sql.ts +40 -7
  176. package/src/lib/repo/types.ts +9 -3
  177. package/src/lib/roles.ts +5 -0
  178. package/src/lib/scheduler.ts +3 -0
  179. package/src/lib/schema.ts +27 -1
  180. package/src/lib/section-ai.ts +55 -0
  181. package/src/lib/section-targets.ts +62 -0
  182. package/src/lib/sections-agent.ts +146 -0
  183. package/src/lib/sections.ts +217 -0
  184. package/src/lib/theme.ts +16 -1
  185. package/src/lib/translate.ts +97 -0
  186. package/src/lib/translations.ts +39 -0
  187. package/src/middleware.ts +32 -0
  188. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  189. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  190. package/src/pages/[...slug].astro +8 -3
  191. package/src/pages/admin/app-auth.astro +159 -0
  192. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  193. package/src/pages/admin/calendar/events/[id].astro +9 -0
  194. package/src/pages/admin/login.astro +6 -2
  195. package/src/pages/admin/logout.ts +5 -2
  196. package/src/pages/admin/mobile.astro +18 -0
  197. package/src/pages/admin/pages/[id].astro +62 -0
  198. package/src/pages/admin/section-preview.astro +61 -0
  199. package/src/pages/admin/users.astro +107 -11
  200. package/src/pages/api/agent/docs.ts +6 -2
  201. package/src/pages/api/agent/index.ts +1 -1
  202. package/src/pages/api/ai/settings.ts +7 -4
  203. package/src/pages/api/design/ai.ts +15 -0
  204. package/src/pages/api/design/index.ts +12 -42
  205. package/src/pages/api/design/translate.ts +15 -0
  206. package/src/pages/api/live/thumb.ts +1 -1
  207. package/src/pages/api/mcp.ts +2 -2
  208. package/src/pages/api/media/upload.ts +10 -78
  209. package/src/pages/api/push/admin.ts +27 -0
  210. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  211. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  212. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  213. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  214. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  215. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  216. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  217. package/src/pages/api/v1/admin/me.ts +6 -0
  218. package/src/pages/api/v1/admin/media/index.ts +32 -0
  219. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  220. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  221. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  222. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  223. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  224. package/src/pages/api/v1/auth/revoke.ts +5 -0
  225. package/src/pages/api/v1/auth/token.ts +5 -0
  226. package/src/pages/api/v1/devices/[token].ts +20 -0
  227. package/src/pages/api/v1/devices/index.ts +17 -0
  228. package/src/pages/api/v1/openapi.json.ts +7 -0
  229. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  230. package/src/pages/api/v1/pages/home.ts +7 -0
  231. package/src/pages/api/v1/push/topics.ts +7 -0
  232. package/src/pages/api/v1/site.ts +7 -0
  233. package/src/pages/api/v1/theme.ts +7 -0
  234. package/src/pages/blog/[slug].astro +8 -2
  235. package/src/pages/blog/index.astro +11 -4
  236. package/src/pages/embed/section/[...path].astro +42 -0
  237. package/src/pages/events/[slug].astro +7 -2
  238. package/src/pages/index.astro +11 -5
  239. package/src/pages/sitemap.xml.ts +8 -0
  240. package/src/styles/admin.css +69 -0
  241. package/src/styles/editor.css +21 -0
  242. package/src/styles/mobile-app.css +151 -0
  243. package/src/styles/sections.css +7 -1
  244. package/src/styles/theme-editor.css +255 -0
  245. package/src/theme-sdk/contract.d.mts +2193 -89
  246. package/src/theme-sdk/contract.mjs +77 -20
  247. package/src/theme-sdk/fields.d.mts +88 -0
  248. package/src/theme-sdk/fields.mjs +326 -0
  249. package/src/theme-sdk/index.ts +10 -2
  250. package/src/theme-sdk/tokens.d.mts +11 -0
  251. package/src/virtual.d.ts +1 -0
package/docs/mobile.md ADDED
@@ -0,0 +1,527 @@
1
+ # Mobile apps: build the owner's own Expo app on their ACMS site
2
+
3
+ This is the guide for agents (and people) working on an ACMS mobile app. The
4
+ site owner edits content in the site admin; the app is **their own** Expo
5
+ (React Native) app that shows it on iOS, Android and the web. ACMS ships the
6
+ foundation (API, client, hooks, native sections, starter, recipes); you build
7
+ the app.
8
+
9
+ > **The site owns the content. The app owns the look and the navigation.**
10
+
11
+ Run every `acms` command from the **site folder** (the one that contains the
12
+ app folder, normally with `astro.config.mjs`), not from the app folder:
13
+ `npx acms …`. The app is a
14
+ separate project beside the site (default `mobile/`, its own `package.json`
15
+ and `node_modules`), found by the `"acms": { "app": "client" }` marker in its
16
+ `package.json`.
17
+
18
+ ## Start and finish
19
+
20
+ ```sh
21
+ npx acms inspect --json # site, themes, mobile apps (dir, kind, bundle ids, versions)
22
+ npx acms docs mobile # this guide
23
+ npx acms add mobile # only when there is no app yet (acms mobile list)
24
+ cd mobile && npm install && cd ..
25
+ npx acms mobile dev # site + Expo on an address a phone can reach
26
+ # … work …
27
+ cd mobile && npx tsc --noEmit && cd ..
28
+ npx acms mobile doctor # must end with 0 failed; read every warning
29
+ ```
30
+
31
+ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
32
+
33
+ ## Rules
34
+
35
+ 1. **Public API only, through `@acms-subir/client`.** Use the hooks from
36
+ `@acms-subir/native` (they wrap the client). For anything without a hook,
37
+ call the typed client from `useAcms()` inside a TanStack Query hook. Never
38
+ raw `fetch`, never `/api/agent`, `/api/design`, `/api/media` or any other
39
+ admin route, never the database.
40
+ 2. **No secrets in the app.** Everything in the bundle is public, including
41
+ every `EXPO_PUBLIC_*` value. No `ACMS_TOKEN`, API keys (`acms_key_…`,
42
+ `acms_agent_…`), admin passwords or `Authorization: Bearer …` literals. The
43
+ only setting is `EXPO_PUBLIC_ACMS_URL`: `.env` (committed) is the address
44
+ baked into builds, set by `acms add mobile --url`, and must be the https
45
+ production site before a store build (it may be a dev address until then);
46
+ `.env.local` (git-ignored) is the dev address `acms mobile dev` writes, and
47
+ wins while developing.
48
+ 3. **Content comes from the site, never hardcoded.** Names, times, text,
49
+ events, prices, people, images, links, giving details, addresses: all read
50
+ through hooks. If the owner should be able to change it, it lives in the
51
+ admin (a page, its sections, events, posts, resources). Only UI chrome
52
+ belongs in code: "Try again", "Book", tab titles, empty-state wording.
53
+ If the content the task needs doesn't exist on the site yet, build the
54
+ screen to read it anyway (by page slug, calendar slug, …), show a clear
55
+ empty state, and tell the user exactly what the owner must create in the
56
+ admin. Don't invent the content in code.
57
+ 4. **Sections render through `<Sections>` and the registry.** A page's
58
+ sections come from `usePage(slug)` / `<Sections page="slug" registry={sections}>`.
59
+ Each section uses the first match of: `sections/index.ts` (yours) →
60
+ the packaged library (`@acms-subir/native/sections`: hero-banner,
61
+ service-times, upcoming-events, feature-grid, team, pricing, gallery,
62
+ questions, cta-band, contact, latest-posts) → the website's rendering in a
63
+ web view → a generic view. Keys are a type or `type:variant`.
64
+ 5. **Native twins mirror the web.** Same fields, same variants, same
65
+ empty-field behaviour as `src/themes/<theme>/sections/<Type>.astro`.
66
+ - Change a library section: `npx acms section add team --theme <slug> --native`
67
+ → `mobile/sections/Team.tsx`, registered. Edit that copy.
68
+ - New coded section: `npx acms section new <type> --theme <slug>` writes the
69
+ web component **and** a typed native stub (`sections/<Type>.tsx`, types in
70
+ `sections/types.ts`). Finish the stub.
71
+ - Build from the exported pieces (`Grid`, `FocalImage`, `SectionHeader`,
72
+ `ActionButton`, `TextLink`, `useSectionMetrics`) so it stays themed and
73
+ responsive. `npx acms check` refreshes `sections/types.ts` and lists
74
+ sections that only show as a web view.
75
+ 6. **Web-view fallback** covers the site's own coded sections until a native
76
+ version exists (needs `react-native-webview`; an iframe on web). Leave it on
77
+ unless every section has a native component. `webFallback={false}` /
78
+ `<AcmsProvider webSectionFallback={false}>` switches to the generic view.
79
+ 7. **Internal links go through `onNavigate`.** Section and rich-text links may
80
+ be root-relative. Open them with `useOpenLink()`; site pages go to
81
+ `<AcmsProvider onNavigate={(path, url) => …}>` in `app/_layout.tsx`, where
82
+ you `router.push` to your own screen (`/events/<slug>` → `app/events/[slug].tsx`)
83
+ and fall back to `Linking.openURL(url)`. External, `mailto:` and `tel:`
84
+ links always leave the app.
85
+ 8. **Languages via `useLocale()`.** `{ locale, defaultLocale, languages, setLocale }`.
86
+ Every hook re-fetches in the chosen language; the starter's
87
+ `LanguageSwitch` shows when the site has more than one. Translate words in
88
+ the admin, not in the app. Date/number formatting: `<EventDate>`,
89
+ `formatEventDate`, `Intl` with `locale`.
90
+ 9. **Theme via `useTheme()`, light and dark.** `t.colors` (`bg`, `ink`,
91
+ `muted`, `line`, `accent`, `onAccent`, …), `t.styles` (`text.*`, `card`,
92
+ `button`, `space(n)`, `fonts`), `t.radius` (≤12), `t.scheme`. No literal
93
+ colors. Check both schemes. No pill shapes (10–12px radius rectangles or
94
+ plain text), no colored left-edge stripes, no gradient-squircle logos.
95
+ 10. **Phones and tablets.** Lay out from `useLayout()` (`columns`, `gutter`,
96
+ `contentWidth`, `wide`) or `useWindowDimensions()`; never fixed widths.
97
+ On a tablet use the width (grids, split panes, `maxWidth` content column),
98
+ not a stretched phone column. `supportsTablet` is on.
99
+ 11. **Touch targets ≥ 44pt** (`minHeight: 44`, or `hitSlop` on small icons);
100
+ `accessibilityRole` and labels on custom buttons; text scales with the
101
+ system font size.
102
+ 12. **Offline.** `offlinePersister()` from `@acms-subir/native/persist` keeps
103
+ content reads for 7 days (install `@react-native-async-storage/async-storage`
104
+ and `@tanstack/query-async-storage-persister` with `npx expo install`).
105
+ Every screen needs loading, error (`<ErrorView error retry>`) and empty states,
106
+ and pull to refresh (`useRefresh()`).
107
+ 13. **Bookings are idempotent.** Use `useBooking({ createKey: randomUUID })`
108
+ (expo-crypto): the same details reuse one Idempotency-Key until success,
109
+ so retries never double-book. Never put the manage token, email or key in
110
+ a URL, log or analytics; keep the manage token in `expo-secure-store`.
111
+
112
+ ## Hooks at a glance
113
+
114
+ | Hook | For |
115
+ | --- | --- |
116
+ | `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `blog`) |
117
+ | `usePage(slug)` | a published page: `mode` (`sections`/`content`), `sections`, `body.html`; 404 when missing |
118
+ | `usePosts({ collection, limit })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`) |
119
+ | `useEvents({ calendar, from, to, limit, q })` | `data.occurrences[]` (`start`, `end`, `allDay`, `event`) in a window (default 30 days, max 90) |
120
+ | `useEvent(slug)` | one event |
121
+ | `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
122
+ | `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
123
+ | `usePushRegistration({ topics })` / `usePushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications) |
124
+ | `useAcms()` | the typed client (e.g. `calendars()`) for your own `useQuery` |
125
+
126
+ Components: `<Sections>`, `<RichText html>`, `<EventDate start end allDay timezone>`,
127
+ `<Button>`, `<ErrorView error retry>`, `<GenericSection>`. Full reference:
128
+ `@acms-subir/native` README; API contract: the site's `docs/mobile-api-v1.md`
129
+ and `<site>/api/v1/openapi.json`.
130
+
131
+ ## Recipes
132
+
133
+ Ready-made screens; add them instead of writing from scratch:
134
+
135
+ ```sh
136
+ npx acms mobile recipe list
137
+ npx acms mobile recipe add events # list, detail, add to calendar (expo-calendar)
138
+ npx acms mobile recipe add news # posts list, article
139
+ npx acms mobile recipe add booking # resources → slots → form → confirmation (expo-crypto)
140
+ npx acms mobile recipe add push # notifications: turn on, topic toggles, taps open the page (expo-notifications, expo-device)
141
+ ```
142
+
143
+ Each prints the `npx expo install …` line for its packages: run it (doctor
144
+ fails when a recipe's packages are missing). Recipes never overwrite files.
145
+
146
+ ## Common tasks
147
+
148
+ **Add a tab.** The starter is a Stack. Move the home screen into a tab group.
149
+ On Expo SDK 57+ import `Tabs` from `expo-router/js-tabs` (the `expo-router`
150
+ export is deprecated):
151
+
152
+ ```
153
+ app/_layout.tsx Stack: <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
154
+ app/(tabs)/_layout.tsx <Tabs screenOptions={{ tabBarActiveTintColor: t.colors.accent,
155
+ tabBarInactiveTintColor: t.colors.muted,
156
+ tabBarStyle: { backgroundColor: t.colors.bg, borderTopColor: t.colors.line },
157
+ headerStyle: { backgroundColor: t.colors.bg }, headerTintColor: t.colors.ink,
158
+ headerRight: () => <LanguageSwitch /> }}>
159
+ app/(tabs)/index.tsx the old app/index.tsx (delete the original; one route per path)
160
+ app/(tabs)/giving.tsx the new tab
161
+ ```
162
+
163
+ Keep detail screens (e.g. `app/events/[slug].tsx`) outside `(tabs)` so they
164
+ push over the tabs. The header title moves to the tab screen
165
+ (`options={{ headerTitle: site?.name }}`). Tab icons: `react-native-svg` is
166
+ already installed (draw small `Path` icons), or run
167
+ `npx expo install @expo/vector-icons` first and use
168
+ `tabBarIcon: ({ color, size }) => <Ionicons name="heart-outline" color={color} size={size} />`.
169
+ To open the tab from site links, map its path in `onNavigate`
170
+ (`router.navigate('/giving')`).
171
+
172
+ **A tab that shows an owner-editable page** (giving, visit, about). Ask the
173
+ owner for (or check for) a page with that slug; render it whichever way it is
174
+ built, and explain what's missing when it isn't there:
175
+
176
+ ```tsx
177
+ export default function Giving() {
178
+ const t = useTheme();
179
+ const page = usePage('giving');
180
+ const { refreshing, refresh } = useRefresh();
181
+ if (page.isPending) return <ActivityIndicator style={{ marginTop: 48 }} color={t.colors.muted} />;
182
+ if (page.error instanceof AcmsError && page.error.status === 404) return <MissingPage slug="giving" />; // your empty state
183
+ if (page.error) return <ErrorView error={page.error} retry={() => page.refetch()} />;
184
+ return (
185
+ <ScrollView style={{ backgroundColor: t.colors.bg }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}>
186
+ {page.data.mode === 'sections'
187
+ ? <Sections page="giving" registry={sections} />
188
+ : <View style={{ padding: t.styles.space(5) }}><RichText html={page.data.body.html} /></View>}
189
+ </ScrollView>
190
+ );
191
+ }
192
+ ```
193
+
194
+ `AcmsError` (from `@acms-subir/client`) has `status` and `code`; show a
195
+ friendly note for `404` rather than a raw error (`isAcmsError(e)` narrows it).
196
+ Fixed chrome (the tab title "Giving") is fine in code; amounts, fund names,
197
+ bank details and links are not.
198
+
199
+ **Show the next 3 events of one calendar on home.** Calendars are addressed
200
+ by slug (`useAcms().calendars()` lists public ones; the site's
201
+ `/api/v1/calendars`). Keep the slug in one named constant, and render the
202
+ page's own sections below it. The events window starts at **midnight UTC
203
+ today** and `from` takes only a date, so the response can include occurrences
204
+ that already ended: ask for a few extra and filter by `end`:
205
+
206
+ ```tsx
207
+ const YOUTH_CALENDAR = 'youth'; // a calendar slug from the site, not content
208
+ const day = (offset: number) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10);
209
+
210
+ function NextEvents() {
211
+ const t = useTheme();
212
+ const open = useOpenLink();
213
+ const to = useMemo(() => day(90), []); // the API's maximum window
214
+ const events = useEvents({ calendar: YOUTH_CALENDAR, to, limit: 8 });
215
+ const next = (events.data?.occurrences ?? []).filter((o) => Date.parse(o.end || o.start) > Date.now()).slice(0, 3);
216
+ // loading / error (404 = calendar renamed or private) / empty states …
217
+ return next.map((o) => (
218
+ <Pressable key={o.id} onPress={() => open(o.event.url)} style={{ minHeight: 44 }} accessibilityRole="link">
219
+ <Text style={t.styles.text.heading}>{o.event.title}</Text>
220
+ <EventDate start={o.start} end={o.end} allDay={o.allDay} timezone={o.event.timezone} />
221
+ </Pressable>
222
+ ));
223
+ }
224
+ ```
225
+
226
+ A weekly event may fill all three slots; that is correct. If the owner should
227
+ choose the calendar, better still: they add an **Upcoming events** section
228
+ (its events source has a calendar and a limit) to the home page in the admin,
229
+ and `<Sections>` renders it with no app code at all. Say so in your summary.
230
+
231
+ **A native version of a coded section.** From the site folder:
232
+ `npx acms section new visit-banner --theme <slug>` (new) or, for an existing
233
+ coded section, write `mobile/sections/VisitBanner.tsx` typed with
234
+ `SectionComponentProps<VisitBannerData>` from `./types` and register it in
235
+ `sections/index.ts` (`'visit-banner': VisitBanner`). Run `npx acms check` to
236
+ refresh `sections/types.ts`.
237
+
238
+ **Translate.** Nothing to translate in the app beyond chrome: content
239
+ languages come from the site (`useLocale`, the `LanguageSwitch`). The owner
240
+ translates in the admin. For chrome strings, keep a tiny `{ en, es }` map
241
+ keyed by `locale`.
242
+
243
+ **Add a booking flow.** `npx acms mobile recipe add booking` then
244
+ `npx expo install expo-crypto`; bookings must be enabled on the site
245
+ (`useSite().data.features.bookings`) with a public booking link. Link to it:
246
+ `<Link href="/book">`.
247
+
248
+ ## Push notifications
249
+
250
+ Optional; the owner sends them from **Admin → Mobile app** (manual messages
251
+ plus automatic ones: new post, new event, "starting soon"). The app only
252
+ registers and chooses topics; it never sends.
253
+
254
+ 1. Site: `acms({ mobile: { push: true } })` in `astro.config.mjs` (off by
255
+ default; `/api/v1/devices` and `/api/v1/push/topics` 404 until then).
256
+ Optional secret `EXPO_ACCESS_TOKEN` when "enhanced push security" is on for
257
+ the Expo project.
258
+ 2. App: `npx acms mobile recipe add push`, `npx expo install expo-notifications expo-device`,
259
+ add `"expo-notifications"` to `plugins` in app.json, `npx eas-cli init`
260
+ (push tokens need `extra.eas.projectId`), and mount `<PushSetup />` inside
261
+ `<AcmsProvider>` in `app/_layout.tsx`. Link to `/notifications`.
262
+ 3. Taps carry `data.url` / `data.path` and go through the provider's
263
+ `onNavigate`, so route `/events/<slug>` and post paths to your screens there.
264
+
265
+ Rules: ask for permission from a button after saying why (the recipe does),
266
+ never on first launch. Topic ids and names come from the site
267
+ (`usePushTopics()`); don't hardcode labels. `usePushRegistration` re-registers
268
+ quietly each start and sends the app's language, so notices arrive in the
269
+ reader's language when a translation exists. Test on a real device (simulators
270
+ get no push); doctor checks the plugin, the project id and that the site has
271
+ push on.
272
+
273
+ ## Editor apps (staff sign-in)
274
+
275
+ A second, separate Expo app for the people who run the site: owners and
276
+ other staff write posts, edit section text, review translations, upload media and
277
+ answer bookings from their phone. It signs in through the site (OAuth 2.0 +
278
+ PKCE in the system browser, never a password in the app) and uses the
279
+ bearer-only `/api/v1/admin/*` API (docs/mobile-api-v1.md → Editor apps). We
280
+ ship building blocks, a blank signed-in shell and optional recipes; the app
281
+ itself is the owner's to shape.
282
+
283
+ ### Start
284
+
285
+ ```sh
286
+ npx acms add mobile editor --url https://example.com # → mobile-editor/
287
+ cd mobile-editor && npm install && cd ..
288
+ npx acms mobile recipe add editor-content # and more, below
289
+ npx acms mobile dev --dir mobile-editor
290
+ ```
291
+
292
+ `add mobile editor` picks a reverse-DNS bundle id (`org.example.staff`), uses
293
+ it lowercased as the app's scheme, and its sign-in return address is
294
+ `<scheme>://auth`. It adds that address to
295
+ `acms({ mobile: { editor: { name, redirectUris } } })` in `astro.config` when
296
+ the file has a shape it can edit safely (`acms()`, `acms({ … })` without a
297
+ `mobile` key, a `mobile: {…}` without `editor`, or an existing
298
+ `redirectUris: [...]`); otherwise it prints the exact snippet (`--no-config`
299
+ never touches the file). Restart or deploy the site afterwards. Other
300
+ addresses to list:
301
+
302
+ - the web build: `https://<where it is hosted>/auth`; `http://localhost/auth`
303
+ covers local development on any port (loopback rule);
304
+ - Expo Go while testing: `exp://<lan ip>:8081/--/auth` (remove before release).
305
+
306
+ `create-acms-subir --mobile editor` (or `both`, `client,editor`) does the same
307
+ while scaffolding.
308
+
309
+ ### The starter
310
+
311
+ - `app/_layout.tsx`: `<EditorProvider baseUrl clientId redirectUri>` and the
312
+ signed-out / signed-in switch (`useAuth().status`).
313
+ - `components/SignIn.tsx`: site name and logo from `/api/v1/site`, "Sign in
314
+ with ‹site›", errors and "you were signed out" notices.
315
+ - `app/index.tsx`: Home — "Signed in as … · role", the app's tools (one file
316
+ per entry in `tools/`), Light / Dark / Auto, sign out.
317
+ - `app/auth.tsx`: the return-address route; `completeAuthRedirect()` lets the
318
+ web sign-in popup hand back its result.
319
+ - `components/Screen.tsx`: `Screen`, `SplitView` (list + detail on ≥ 768),
320
+ `useWide`, `HeaderActions`; `components/TextTabs.tsx`; `lib/format.ts`.
321
+
322
+ The look is the admin's Inset Paper (light and dark): `useEditorTheme()`
323
+ (`colors.panel/raised/sel/line/text/muted/accent/green/blue/amber/red`,
324
+ `text.screenTitle/title/row/body/meta/label/section`, `radius`). Change tokens
325
+ with `<EditorProvider theme={{ colors: { light: { accent: '#0a7' } } }}>`.
326
+
327
+ ### `@acms-subir/native/editor`
328
+
329
+ No hard dependencies. Optional peers, loaded only if installed (a missing one
330
+ throws a message naming the package): `expo-secure-store` (tokens),
331
+ `expo-web-browser` (sign-in), `expo-crypto` (PKCE; Web Crypto otherwise),
332
+ `expo-image-picker` (uploads), `expo-device` (device name).
333
+
334
+ `<EditorProvider>` props: `baseUrl`, `clientId`, `redirectUri` (use
335
+ `redirectUriFor(scheme)`: `<scheme>://auth` on devices, `<origin>/auth` on the
336
+ web), `storage` (default: expo-secure-store with
337
+ `WHEN_UNLOCKED_THIS_DEVICE_ONLY`; sessionStorage on the web; or
338
+ `memoryTokenStorage()` / your own `{ get, set }`), `storageKey`,
339
+ `openAuthSession`, `crypto`, `deviceName`, `queryClient` (default: the
340
+ surrounding one or a new one), `colorScheme`, `theme`, `fetch`. It refreshes
341
+ tokens before they expire (single flight), signs out locally when a refresh is
342
+ rejected or a request is still 401 after a refresh, and drops all cached admin
343
+ data on sign-out.
344
+
345
+ | Hook | Gives you |
346
+ | --- | --- |
347
+ | `useAuth()` | `status` (`loading`/`signedOut`/`signingIn`/`signedIn`), `user`, `role`, `device`, `error`, `signedOutReason`, `signIn()`, `signOut()` |
348
+ | `useSiteInfo()` / `useMe()` | public site name + logo / the person, site features and `permissions` |
349
+ | `useDocuments(query)` | infinite list (`kind`, `status`, `collection`, `q`); flat `items`, `total` |
350
+ | `useCreateDocument()` | mutation: `{ kind, title, collection? \| start? }` |
351
+ | `useDocument(kind, id)` | `document`, `save(fields)` (sends the loaded revision), `saving`, `conflict` (`server`, `mine`), `keepMine()`, `keepTheirs()` |
352
+ | `usePublish(kind, id)` | `publish()` (Update for live docs with staged edits), `unpublish()`, `schedule(at)`, `discard()`, `action` |
353
+ | `useSectionLayouts({ stage, pageId })` | pages with sections, definitions, revision, role |
354
+ | `useSectionEditor(pageId, sectionId)` | `data`, `setField`, `dirty`, `issues`, `permissions`, `canEdit(key, sub?)`, `save()` (only changed fields), `publish()` (this section), `conflict`/`keepMine`/`keepTheirs`, `reset()` |
355
+ | `useTranslations(target, locale)` | `units` (source, value, `missing`/`ai`/`reviewed`/`outdated`), `summary`, `set(values)`, `markReviewed(paths?)`, `aiDraft(paths?)` |
356
+ | `useTranslationQueue(locale)` | sections + newest documents that still need a person |
357
+ | `useMedia(query)` | infinite library, `upload(file)`, `pickAndUpload({ source, quality, aspect })` |
358
+ | `useBookingsInbox({ status })` | infinite inbox, `pending`, `approve/decline/cancel(id, message?)` |
359
+ | `useEditorClient()` | the typed bearer client for anything else |
360
+
361
+ `target` is `{ kind, id }` for a post/page/event or `{ pageId, sectionId }`.
362
+ Errors: `errorMessage(e)`, `isConflict(e)` (409), `isForbidden(e)` (403).
363
+
364
+ Section saves are guarded by the design revision, which changes whenever
365
+ anyone saves anything. `useSectionEditor` re-applies your changed fields on
366
+ the latest copy automatically; only when someone changed the **same** fields
367
+ does `conflict` ask. Documents never auto-merge.
368
+
369
+ **Forms.** `<FieldInput field value onChange siblings role readOnly>` renders
370
+ any section field type — `text`, `textarea`, `richtext`, `url`, `number`,
371
+ `toggle`, `select` (option buttons, a sheet over 8 options), `date`, `time`,
372
+ `link` (label, address, "Pick" a page/post/event → `ref`), `image` (media
373
+ library or upload, tap to set the focal point `<key>__focus`), `list` (add,
374
+ remove, reorder rows; `_id`s kept so translations follow rows), `source`
375
+ (calendar / collection / resource from the site, tag, how many) — with the
376
+ label, `required` marker, `maxLength` counter, help text, validation after
377
+ blur, `visibleIf`, and read-only for editors when a field is `locked`.
378
+ `<SectionFields definition data onChange permissions role>` renders a whole
379
+ section. The rules are the server's (a dependency-free copy of
380
+ `theme-sdk/fields.mjs` with a drift test): `normalizeData`, `valueIssues`,
381
+ `fieldVisible`, `formIssues`, `sectionPermissions`, `translationUnits`, …
382
+
383
+ **Rich text** edits "markdown-lite" and stores a safe HTML subset
384
+ (`<p> <br> <strong> <em> <a href> <ul> <ol> <li> <h2> <h3> <blockquote>`):
385
+ `**bold**`, `*italic*`, `[label](https://…)`, `- ` bullets, `1. ` numbers,
386
+ `## ` headings, `> ` quotes, with a B / I / Link / List / H toolbar. HTML with
387
+ anything richer (images, embeds, cards, tables, classes, nested lists) opens
388
+ read-only with "Edit this on the website" (`richTextSupport(html)`), so the
389
+ phone never silently drops content.
390
+
391
+ Building blocks: `EditorButton`, `EditorInput`, `Segmented`, `Notice`,
392
+ `StatusText`, `SectionLabel`, `ListRow`, `Card`, `Sheet`, `OptionGrid`,
393
+ `MediaPicker`, `DocumentPicker`.
394
+
395
+ ### Cookbook (editor apps)
396
+
397
+ **A screen of your own** (add `tools/notices.ts` so Home lists it):
398
+
399
+ ```tsx
400
+ // app/notices.tsx: edit one section's "notice" text from Home
401
+ import { EditorButton, FieldInput, Notice, errorMessage, useAuth, useSectionEditor } from '@acms-subir/native/editor';
402
+ import { Screen } from '../components/Screen';
403
+
404
+ export default function Notices() {
405
+ const { role } = useAuth();
406
+ const ed = useSectionEditor(PAGE_ID, SECTION_ID); // from useSectionLayouts(), never hardcoded content
407
+ if (!ed.definition) return null;
408
+ return (
409
+ <Screen>
410
+ <FieldInput field={ed.definition.fields.text} value={ed.data.text} onChange={(v) => ed.setField('text', v)}
411
+ role={role ?? undefined} readOnly={!ed.canEdit('text')} />
412
+ {!!ed.saveError && <Notice tone="error">{errorMessage(ed.saveError)}</Notice>}
413
+ <EditorButton variant="primary" label="Save and publish" busy={ed.saving || ed.publishing} onPress={() => ed.publish()} />
414
+ </Screen>
415
+ );
416
+ }
417
+ ```
418
+
419
+ **Only owners see a button:** `const { data: me } = useMe(); me?.permissions.sections.editLocked && <EditorButton … />`.
420
+
421
+ **A data call the hooks don't cover:** `const api = useEditorClient();`
422
+ then `useQuery({ queryKey: editorKey(baseUrl, user?.id, 'bookings-week'), queryFn: ({ signal }) => api.listBookings({ from }, { signal }) })`;
423
+ keep keys under `editorKey(...)` so sign-out clears them.
424
+
425
+ **Test sign-in on the web:** list `http://localhost/auth` in `redirectUris`,
426
+ `npx expo start --web` in the app, sign in in the popup.
427
+
428
+ ### Recipes (editor apps)
429
+
430
+ ```sh
431
+ npx acms mobile recipe add editor-content # list, kind/status filters, search, publish/unpublish/discard
432
+ npx acms mobile recipe add editor-writer # new post/page/event, title/summary/body/tags/address, save, publish, schedule, conflicts
433
+ npx acms mobile recipe add editor-sections # page → section → form → save → publish this section (expo-image-picker)
434
+ npx acms mobile recipe add editor-translations # "Review Español": missing / AI drafts / outdated; edit beside the original, approve, AI draft
435
+ npx acms mobile recipe add editor-media # grid + upload from library or camera (expo-image-picker)
436
+ npx acms mobile recipe add editor-bookings # inbox: approve / decline / cancel with a message
437
+ ```
438
+
439
+ Recipes know their app kind: client recipes refuse editor apps and the other
440
+ way round; with both apps in the site, `recipe add` picks the matching one.
441
+ Each adds a `tools/<name>.ts` entry, so it shows on Home. Tablets get list +
442
+ detail side by side. Push for editors ("new booking", "draft to review") is
443
+ not supported yet: the push API registers anonymous visitor devices only.
444
+
445
+ ### Rules for editor apps
446
+
447
+ - Never ask for or store a password; never put the owner password, API keys
448
+ or agent keys in an app. Sign-in is `useAuth().signIn()`.
449
+ - Tokens live only in secure storage through `<EditorProvider>` (never
450
+ AsyncStorage, files, logs, analytics or crash reports; only the
451
+ `Authorization` header). Don't read them into React state.
452
+ - Hide what the role can't do (`useMe().data.permissions`, `canEdit`,
453
+ `permissions.canEditRows`), but always let the server decide; show 403 as
454
+ "your role can't do that".
455
+ - Every write sends the revision you loaded; on 409 show the conflict and let
456
+ the person choose — never retry blindly over someone's work.
457
+ - Same design rules as the client app: no pill shapes (8–12px rectangles,
458
+ text tabs, marker + text), no colored left-edge stripes, no gradient-squircle
459
+ logos; phones and tablets (≥ 768 uses the width).
460
+
461
+ ### Without the starter
462
+
463
+ `@acms-subir/client` alone: `createEditorAuth` (authorize URL, `parseRedirect`
464
+ with the state check, code exchange, refresh, revoke), `createEditorSession`
465
+ (storage adapter, single-flight refresh) and `createEditorClient`. Pass
466
+ expo-crypto as `crypto` when Web Crypto isn't available.
467
+
468
+ `acms mobile doctor` checks editor apps too: expo-secure-store,
469
+ expo-web-browser and expo-crypto installed; app.json's scheme gives a return
470
+ address the site lists (from `.astro/acms-project.json`) and the running site
471
+ has editor apps on; no tokens in AsyncStorage, no logged tokens, no password
472
+ fields.
473
+
474
+ ## Deep links (universal / app links)
475
+
476
+ 1. Site: `acms({ mobile: { ios: { teamId, bundleId, paths }, android: { package, sha256CertFingerprints } } })`
477
+ in `astro.config.mjs`. The site then serves
478
+ `/.well-known/apple-app-site-association` and `/.well-known/assetlinks.json`.
479
+ `bundleId` / `package` must equal `app.json`. The Android fingerprint comes
480
+ from `npx eas-cli credentials` (the release signing key).
481
+ 2. App (`app.json`): `ios.associatedDomains: ["applinks:example.com"]`;
482
+ `android.intentFilters: [{ "action": "VIEW", "autoVerify": true, "data": [{ "scheme": "https", "host": "example.com" }], "category": ["BROWSABLE", "DEFAULT"] }]`;
483
+ `scheme` for `myapp://` links (the starter sets it).
484
+ 3. Expo Router opens `https://example.com/events/x` at the route
485
+ `app/events/[slug].tsx` when the file path matches. Map other site paths in
486
+ `app/+native-intent.tsx` (`export function redirectSystemPath({ path })`).
487
+
488
+ Doctor checks that ids match and the files serve.
489
+
490
+ ## Test
491
+
492
+ - iOS simulator, Android emulator and `npm run web` (web preview), light and
493
+ dark, phone and tablet sizes. Compare with the website: same sections,
494
+ same order, same words.
495
+ - A real phone: `npx acms mobile dev` (same Wi-Fi or Tailscale).
496
+ - `npx tsc --noEmit` in the app; `npx expo export --platform web` bundles.
497
+ - `npx acms mobile doctor` (add `--offline` without network).
498
+
499
+ ## Release (EAS)
500
+
501
+ ```sh
502
+ cd mobile
503
+ npx eas-cli init # links an EAS project
504
+ npx eas-cli build --profile preview --platform all # installable test builds
505
+ npx eas-cli build --profile production --platform all
506
+ npx eas-cli submit --platform ios # and android
507
+ ```
508
+
509
+ Store checklist: real `ios.bundleIdentifier` / `android.package` (can't change
510
+ after release; doctor warns on `com.example.*`); app name, `icon` (1024×1024)
511
+ and splash in `app.json`; `version` and build numbers bumped; `.env` points at
512
+ the **https** production site; privacy policy URL; screenshots for phone and
513
+ tablet; deep-link config deployed on the site; a production build tested on
514
+ real devices; `acms mobile doctor` clean.
515
+
516
+ ## Doctor
517
+
518
+ `npx acms mobile doctor [--dir mobile] [--url https://site] [--offline] [--json]`
519
+ checks every app (or `--dir`): packages and versions compatible with the
520
+ site's core, Expo SDK; the site URL is set, reachable from a phone and on the
521
+ same API version; app ids and scheme; deep-link files; sections on the site's
522
+ pages the app can't render; pages and calendars the app reads by slug that
523
+ the site doesn't publish; secrets in source or env files; recipe packages
524
+ (and for push: the `expo-notifications` plugin, `extra.eas.projectId`, and
525
+ push turned on for the site).
526
+ Exit 1 when anything fails. Network checks become warnings when the site is
527
+ unreachable; `--offline` skips them.