Docs ios PLATFORM-SPEC.md
Substratal Hub for iOS & iPadOS — Platform Spec
Standard: Apple Human Interface Guidelines (iOS/iPadOS 26, Liquid Glass via standard components).
Read with: SCREENS.md (all 75 screens with SwiftUI controls), shared/.
1. Technology
| Concern | Choice |
|---|---|
| UI | SwiftUI, minimum iOS 17 |
| Shared code | SubstratalKit Swift package with macOS: generated API client, services, Capabilities, persistence |
| Secure storage | Keychain kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, never iCloud-synced |
| Cache | SwiftData / files with NSFileProtectionComplete |
| Distribution | App Store (universal iPhone + iPad); TestFlight for betas |
2. Navigation
iPhone — TabView, each tab its own NavigationStack (path persisted with @SceneStorage):
| Tab | Root | Pushes |
|---|---|---|
| Home | APP-01 | APP-02 (sheet), APP-04, SET-02, APP-05 |
| Inbox (badge) | NTF-01 | NTF-02 |
| Orgs | ORG-01 | ORG-03/06/08, BIL-02, ORG-04 (sheet) |
| Manage | GLB-02 hub (Administration + Developer) | USR-, ACX-, ROL-, AUD-, NTF-05/06, APL-* (incl. APL-07), KEY-, WHK-, BIL-04 |
| Account | ACC-01 | ACC-04, NTF-03, ACC-07, BIL-01 (Plan & Billing), SET-01 (Settings) |
The Manage tab is removed (not disabled) when the person has no admin or developer rights — an end user sees 4 tabs: Home · Inbox · Orgs · Account.
iPad — TabView with .tabViewStyle(.sidebarAdaptable): sidebar shows the full desktop tree (same as macOS); sections with list-detail use NavigationSplitView. Supports Split View, Slide Over, Stage Manager, multiple windows (scene per window, WindowGroup(for: Route.self)), and resizing without losing state.
3. Interaction
| Interaction | Behavior |
|---|---|
| Long-press | .contextMenu with preview on tiles/rows/IDs (same items as ⋯ / toolbar) |
| Swipe actions | .swipeActions on rows: leading safe (Accept, Turn on, Mark read), trailing destructive (Turn off, Remove, Revoke → confirm). Full swipe only for non-destructive. |
| Pull to refresh | .refreshable on every list |
| Multi-select | EditButton / "Select" → List(selection:); two-finger pan to select on iPhone/iPad; bulk actions in bottom toolbar |
| Drag & drop | iPad: drag users onto an org in the sidebar; drag app tiles to reorder pins; drop CSV/TXT/images/JSON from Files/other apps (.dropDestination); drag export/audit event out (Transferable) |
| Pointer (iPad trackpad/mouse) | .hoverEffect, right-click = context menu, hover preview popovers on names |
| Keyboard (iPad) | same shortcuts as macOS where valid (⌘K, ⌘F, ⌘R, ⌘N, ⌘Z, ⌘⌫, ⌘↩, ⌘1–4) via .keyboardShortcut, visible in the ⌘-hold overlay |
| Pencil | precise pointer; Scribble in all text fields (default); hover with Pencil Pro shows previews |
| Haptics | .sensoryFeedback(.success) on grant/turn-on, .warning before turn-off confirm, .selection on segmented changes |
| Toast | glass capsule overlay above the tab bar (custom), VoiceOver announcement |
| Undo | toast Undo (10 s) + shake-to-undo |
| Secret reveal | sheet with interactiveDismissDisabled; content hidden in app switcher snapshot and replaced during screen recording (UIScreen.isCaptured) |
4. Platform integration
| Concern | Implementation |
|---|---|
| Autofill | Associated Domains (webcredentials:), text content types; Passwords app saves on sign-in |
| Passkeys | ASAuthorizationController platform passkeys when flag passkeys is on |
| One-time codes | .textContentType(.oneTimeCode) (Passwords app supplies TOTP codes when the user stored the setup there via otpauth://) |
| Biometric lock | optional Face ID/Touch ID on open (NSFaceIDUsageDescription) |
| Deep links | Universal Links + substratal:// → Route |
| App launch / PKCE | openURL / ASWebAuthenticationSession |
| Billing | BIL-01/02 show plan, coverage and status; Checkout/Portal open in Safari (openURL) with Universal Link return and polling. Upgrade is gated by billing_upgrade_ios, default off until App Store policy review (OPEN-QUESTIONS #10): Standard is a consumer subscription unlocking digital apps. |
| Push | APNs as app_hub — see §4a; app icon badge = unread-count via UNUserNotificationCenter.setBadgeCount |
| Share | ShareLink for links and export files |
| Files | fileExporter/fileImporter, document picker; camera + PhotosPicker for avatar |
| Widgets / Controls | Home Screen widget "Quick launch" (pinned apps); Control Center control "Open Substratal" |
| App Intents | "Open app {name}", "Turn off access for {person} in {app}" (requires confirmation) for Shortcuts/Siri/Spotlight |
| Privacy | PrivacyInfo.xcprivacy; in-app account deletion = ACC-08 (App Store requirement) |
4a. Push notifications (APNs)
| Step | Implementation |
|---|---|
| Bundle id | Shared with the macOS app (Universal Purchase) — one app_hub APNs credential/topic |
| Capabilities | Push Notifications (aps-environment), Time Sensitive Notifications (for security.* alerts sent with interruption-level: time-sensitive), Background Modes → Remote notifications (silent badge refresh) |
| Permission | NTF-04 primer at the first meaningful moment after sign-in → requestAuthorization([.alert, .badge, .sound]); never before sign-in; "Not now" snoozes 14 days |
| Register | UIApplication.registerForRemoteNotifications() → token hex → POST /users/me/devices {application_id: "app_hub", provider: "apns", token, environment (sandbox for debug/dev builds, production for TestFlight/App Store), name: device name, app_version}; store the dvc_ id |
| Refresh / rotate | Re-register once a day on launch; a changed token in didRegisterForRemoteNotificationsWithDeviceToken → PATCH /users/me/devices/{id} |
| Categories & actions | UNNotificationCategory per category: security.new_sign_in (It was me, Review sessions), other security.* (Secure my account), account.organization_invitation (Accept — .authenticationRequired, View), account.access_granted (Open app) |
| Foreground | willPresent → in-app banner (no system banner), refresh inbox + badge |
| Tap / action | didReceive → PATCH …/notifications/{id} {state:read} → route by action_url; Accept → POST /organizations/{id}/members/me/accept |
| Turn off | NTF-03 → DELETE /users/me/devices/{id}; OS-level revocation needs no call (server deactivates on next send) |
| Lock screen | Show title/body as sent; respect "Show Previews" — never add detail |
4b. Brand & app icon
Assets: brand-assets/ (see shared/brand/BRAND.md for rules).
| Item | File | Wiring |
|---|---|---|
| App icon | Assets.xcassets/AppIcon.appiconset holds single-size 1024 px icons: light (opaque, no alpha), dark (transparent) and tinted (grayscale). |
Set App Icon to AppIcon. The system derives every size. |
| Liquid Glass icon (iOS 26+) | icon-composer-layers/ (background + 3 groups) |
Build AppIcon.icon in Icon Composer and add it to the target. The flat set above is the fallback. |
| Older toolchains | legacy-sizes/Icon-*.png |
Only if the project still needs per-size slots |
| Logo images | Assets.xcassets/Brand/BrandMark, BrandMarkSmall, BrandLockup, BrandLockupInline, BrandLockupStacked (SVG, preserves vector, light + dark) |
Image("Brand/BrandLockup").accessibilityLabel("Substratal Apps") |
| Brand colours | Assets.xcassets/Brand/*.colorset (light + dark) |
Color("Brand/BrandPink"). For icons and logo only; UI colour comes from theme/SubstratalTheme.swift (§4c). |
| Launch screen | LaunchScreen.md |
UILaunchScreen → Brand/BrandMark on Brand/IconBackground |
Placement: horizontal lockup centred above the AUTH-01 form, with 32 pt above it. Settings › About shows the lockup and version. Notification content uses the app icon. The iPad sidebar header has no logo.
4c. Theming
theme/SubstratalTheme.swift (in SubstratalKit) and theme/Assets.xcassets/AccentColor (defaults).
- Wrap the root view in
.substratalTheme(store). Views read colours from@Environment(\.palette), never from literals. List/Formgrouped background usespalette.background, cells usepalette.raised(.listRowBackground), and tint comes frompalette.accent(oraccentStrongunder Increase Contrast).- The tab bar, navigation bars and sheets keep system materials. Only tint and the content backgrounds change.
- SET-01 swatches are a three-column grid inside a Form section.
- Persist the choice per THEMES.md §1. Reference screens are in
screens/lightandscreens/dark.
5. Layout metrics
- Inset-grouped lists; system corner radii and margins (don't hard-code).
- Dynamic Type text styles only:
.largeTitletab roots,.headline,.body,.subheadlinerow subtitles,.footnotecaptions; IDs.monospaced(). - Targets ≥ 44 × 44 pt. Grid: 2 columns (iPhone), 3–5 (iPad, adaptive 160 pt).
- Icons: SF Symbols per INFORMATION-ARCHITECTURE §5.
6. Accessibility
Dynamic Type to AX5 (grid → 1 column, trailing row content wraps below via ViewThatFits); VoiceOver custom actions mirror context menus and swipe actions; Voice Control labels match visible text; Bold Text, Reduce Motion, Reduce Transparency, Increase Contrast honored; Switch Control reachable for all actions.
7. Testing checklist
- APNs end to end with a Hub device test (NTF-03): register, rotate, sign-out deactivation, time-sensitive security alerts, Accept-invitation action from the lock screen.
- Billing: plan states (Free, Standard own, Standard via org, past_due, canceled); Upgrade hidden while
billing_upgrade_iosis off. - iPhone SE → Pro Max; iPad mini → Pro 13" in every multitasking size; keyboard + trackpad + Pencil attached simultaneously.
- 11 flows with VoiceOver, Switch Control, largest Dynamic Type.
- Persona matrix — tab set and every action.
- Universal Links from Mail for verify/reset/invite with app installed and not installed.
- Background/foreground: refresh single-flight, inbox badge correct.