Obscura Mobile — iOS HIG audit, device pass
Date: 2026-07-27 · Build: main @ a54719c + this branch · Toolchain: Xcode 27.0 (27A5218g), iOS 27.0 simulator runtime (24A5380i), iPhone 17 Pro simulator · Target: iOS 16.4+, iPhone portrait
Why this document exists alongside the first audit
2026-07-27-ios-hig-audit.md was written without an Apple toolchain. It said so honestly and listed exactly what it could not close:
Genuinely device-only — cannot be closed without a Mac + iPhone: H7 Dynamic Type at accessibility sizes (does a 44pt button clip at AX3?); whether react-navigation extends the header button's hit area; VoiceOver rotor order, haptic feel, ProMotion scrolling.
This pass ran the app. Its job is to (a) verify the first audit's "Closed" claims rather than inherit them, and (b) settle the device-only items. It found that the headline open item is worse than the first audit guessed, and that the haptics work — which the first audit marked "Closed in code" — covered about a fifth of the acts its own stated rule demands.
1. Verification of the previous audit's closed items
Every one re-checked from primary evidence, not from the previous table.
| Finding | How I verified | Result |
|---|---|---|
| H1 unused camera + mic permissions | expo config --type introspect — the real plugin pipeline |
Confirmed closed. Only NSFaceIDUsageDescription and NSPhotoLibraryUsageDescription resolve. app.json sets cameraPermission: false, microphonePermission: false |
| H2 boilerplate purpose strings | same introspection | Confirmed closed. Both strings name a specific reason ("…to unlock the app and to confirm that a signature is yours") |
| §2 placement presets (was 82×25) | source | Confirmed closed — minHeight: 44 |
| §2 incognito toggle (was 22×24) | source | Confirmed closed — now an IconButton: 44×44 floor, hitSlop={10}, and a required accessibilityLabel enforced by the prop type |
§2 AI tab (was 40×46) |
source | Confirmed closed — minWidth: 44 |
| H3 sheets with no dismiss | source | Confirmed closed — headerLeft on both share and upload |
| H4 FAB over the home indicator | source | Confirmed closed — bottom: spacing[6] + insets.bottom |
| H5 placeholder contrast | recomputed the WCAG ratios myself | Confirmed closed. Light #6f6f6f on #ffffff = 5.02:1; dark #8d8d8d on #262626 = 4.56:1. Both clear 4.5:1 (the token comment says 4.6:1 for the light pair; the arithmetic gives 5.02 — either way it passes) |
| H6 haptics | source + build | Reopened. The module is right; its application was not — see §3 |
| H7 Dynamic Type | measured across 6 content sizes on the simulator | Confirmed as a hard defect — see §2 |
| Header avatar hit area (was 62×30, "needs device confirmation") | source | Closed, and the device question is moot. (tabs)/_layout.tsx now declares its own minHeight: 44 / minWidth: 44 on the headerRight Pressable, plus accessibilityRole and accessibilityLabel. It no longer depends on whether react-navigation extends the hit area to the bar height — which is why this needed no device after all |
Also still true and still right: back-swipe is never disabled (0 gestureEnabled overrides), destructive actions use native Alert with style: 'destructive', and — importantly — allowFontScaling is disabled nowhere in the app. Suppressing Dynamic Type wholesale is the common cheat and this codebase does not take it.
2. H7 Dynamic Type — confirmed, and worse than predicted
The first audit predicted the failure mode as "Button, Chip and compact Row carry fixed minHeight: 44 with fixed padding, so at the accessibility sizes labels will overflow or clip."
That prediction was wrong, and the real defect is more serious. Measured with xcrun simctl ui <device> content_size <size> on the connect screen:
| Content size | Result |
|---|---|
large (default) → accessibility-medium (AX1) |
Fine. Text reflows, nothing clipped |
accessibility-large (AX2) |
Fits, but fills the viewport edge to edge |
accessibility-extra-large (AX3) |
Brand lockup collides with the status bar; Connect button flush against the bottom edge |
accessibility-extra-extra-large (AX4) |
Lockup overlaps the clock; "ENTERPRISE DMS" clipped horizontally; Connect button cut off |
accessibility-extra-extra-extra-large (AX5) |
Connect button entirely off-screen — the screen cannot be completed |
Button never clipped its label at any size: minHeight: 44 is a floor, the view grows, and the label wrapped correctly throughout. The component kit was fine.
The actual defect is the screen layout. server, login and totp centred their content inside a non-scrolling flex: 1 container:
<Screen>
<KeyboardAvoidingView style={{ flex: 1, justifyContent: 'center' }}>
While the content fits, centring is invisible and pleasant. Once Dynamic Type makes the same content taller than the viewport, a centred overflow spills off both ends at once — the lockup climbs into the status bar and the primary button leaves the bottom of the screen, with no scroll to reach it. There is no error state, nothing looks broken: the button is simply not there.
Severity: at the largest accessibility text sizes a user could not sign into the app at all. That is an accessibility barrier on the entry path, and Dynamic Type support is a HIG requirement rather than a nicety.
Fixed
- New
CenteredScreenincomponents/ui.tsx:KeyboardAvoidingView→ScrollViewwithcontentContainerStyle={{ flexGrow: 1, justifyContent: 'center' }}. Keeps the centred look while content fits, degrades to an ordinary scroll when it does not. Adopted byserver,login,totp. - It also applies safe-area insets top and bottom. These screens carry no nav bar, so nothing else reserves the status bar for them; invisible while centred, load-bearing the moment the content scrolls.
ObscuraBrandgetsmaxFontSizeMultiplier={1.4}andflexShrink: 1. This is the one place a scaling cap is correct — it is the brand lockup, not content. Dynamic Type exists to keep text legible; the logotype is already a graphic, and uncapped it grows past the screen edge and shoves the form off the top. Every content style (titles, body, labels, buttons) is deliberately left uncapped.
Re-verified at AX5 after the fix: content is top-aligned and scrollable, and "ENTERPRISE DMS" wraps instead of clipping.
Same defect, NOT fixed: documents/[id]/otp.tsx
otp.tsx:138 is the identical pattern — <View style={{ flex: 1, justifyContent: 'center' }}> with no scroll, wrapping a code field and a Done button. By inspection it fails the same way at accessibility sizes, and it is the signing ceremony.
I did not convert it. It carries a custom keyboardVerticalOffset={88} and a paddingBottom: insets.bottom, both tuned around a numeric keypad, and the screen sits behind authentication — with no backend running I could not test that a ScrollView leaves the keyboard behaviour intact. Changing keyboard handling in the highest-stakes screen in the app on an untested guess is a worse trade than reporting it precisely. Recommend the same CenteredScreen treatment plus a device pass on the OTP keypad.
Screens checked and not affected: ai-chat, sign, view, (tabs)/index — their centred styles wrap inner elements, and the scrollable ones already scroll.
3. Haptics — the vocabulary was right, the coverage was not
lib/haptics.ts states a genuinely good rule: haptics are a feedback vocabulary, reserved for moments where something irreversible happened to a document, plus the failures of those same acts; navigation and list taps stay silent. That is correct HIG thinking and it is why this section is about application, not philosophy.
Measured coverage before this pass — mutation call sites vs. haptic call sites:
| Screen | Mutations | Haptics |
|---|---|---|
documents/[id]/sign · stamp · meterai |
1 each | 3–4 each ✅ |
documents/[id]/index |
10 | 3 |
documents/[id]/otp |
2 | 0 |
documents/[id]/share |
4 | 0 |
documents/[id]/request-signature |
1 | 0 |
signatures |
3 | 0 |
upload |
4 | 0 |
The three dedicated ceremony screens were done properly. Everything else was not, and three gaps stood out:
otp.tsxwas completely silent — and it is where the official signature actually lands. Sign with the simple tier and you feel the success; sign with the official tier (the legally strongest, OTP-backed path) and the ceremony completes inotp.tsxwith no feedback at all. The strongest act in the product was the one that said nothing.sign.tsximportedhapticSuccessandhapticWarningbut neverhapticError— so a failed signature was silent while a failed stamp buzzed, contradicting the module's own "plus the failures of those same acts".doPublishanddoForwardsat in the same file as covered acts with nothing — publishing cuts a new version, forwarding routes a task behind a destructive confirm.
The rule now written down
Fire when the app commits something the user cannot casually undo — a document changed, access granted or revoked, a credential created — or when such an attempt fails. Stay silent when the system already answers loudly on its own (a share sheet sliding up, a screen pushing), and for navigation, list taps and chip selection.
What now fires
| Act | Feedback |
|---|---|
| Signed / stamped / e-Meterai affixed / approved / rejected | success (existing) |
| OTP ceremony completes — the signature lands | success |
| Envelope: your slot signed, others still to go | success (your own act is committed) |
| Wrong OTP code | error — the field clears under you; the tap says "rejected", not "swallowed" |
| Sign fails | error (was silent) |
| Publish / forward + their failures | success / error |
| Share link revoked, internal link removed | success — the only other trace is a row leaving a list |
| Signature request sent | success |
| Signature drawn/imported/deleted | success — the canvas clears under you; without the tap that clear is ambiguous |
| Upload complete (single + all-ok batch) | success — a long async task ending, HIG's notification case |
| Upload partial / incomplete / retry failed | warning |
Two feedback types that were entirely missing
The module only had the three notification patterns. iOS defines two more, and both had a real home here:
- Selection (
selectionAsync) — the appearance/languageSegmentedcontrol and the AI incognito toggle. iOS gives segmented controls and picker wheels a selection tick; matching it is part of what stops a drawn control feeling drawn. Fires only on an actual change — re-tapping the current segment changes nothing, so it says nothing. - Impact (
impactAsync) — the placement editor, which is HIG's textbook case: a mark being dragged into position, "snapping into place".mediumwhen a preset flings the box to a corner (big positional jump),lightwhen tap-to-place drops it, andlightwhen a drag collides with a page edge. The collision tap is latched so it fires once on arrival rather than on every move event while the finger stays out there.
Deliberately still silent
Tab switches, list taps, ordinary chips/filters, navigation, AI ask/summarize (nothing is committed), and share-link creation — that one hands off to the native share sheet, which is a loud enough answer on its own. Its failure does buzz, because then no sheet appears and the tap explains the silence.
4. Incidental defects found while wiring the above
otp.tsx ran side effects during render.
// Poll outcome while a seal is landing.
if (landing && seal.data?.status === 'completed') {
finish(seal.data.signed_version) // Alert.alert + router.dismissTo, in render
}
finish() alerts and navigates. A render-phase side effect re-runs on every re-render, so the success alert could fire repeatedly — and the project has reactCompiler: true, which is least forgiving of exactly this. Moved into a useEffect keyed on the seal status, with a single-shot ref latch. Found because adding the success haptic here would have buzzed on every re-render.
a54719c shipped expo-haptics without the lockfile — main did not build.
The haptics commit added expo-haptics to mobile/package.json and touched seven files; pnpm-lock.yaml was not among them, and git show a54719c:pnpm-lock.yaml | grep expo-haptics returns nothing. Consequences:
pnpm install --frozen-lockfile— the CI default — fails on a package.json/lockfile mismatch.- A fresh clone never installs the module, so Metro cannot resolve it and the iOS build dies at the bundling phase:
Unable to resolve module expo-haptics from src/lib/haptics.ts. I hit exactly this on the first simulator build.
Fixed here by running pnpm install (the lockfile entry is part of this change) and pod install — it is a native module, so it needs the pod too. Anyone who pulled a54719c needs both before the app will build.
5. Still open
| Item | Why it is still open |
|---|---|
| VoiceOver rotor order | The interesting screens are behind auth, and driving the simulator UI turned out not to be possible here (see below) |
| Haptic feel | The simulator has no Taptic Engine. Verified the code paths, the ExpoHaptics pod linking into the binary, and the symbols in the shipped bundle — not the sensation. That still needs the phone in your hand |
otp.tsx scroll fix |
§2 — deliberate, with reasons |
expo-glass-effect |
Still 0 references in src/. Dead weight in the bundle: use it or drop it |
supportsTablet: true |
Still true with a phone-only layout. If iPad is not a target, setting it false avoids being reviewed against iPad HIG |
No headerLargeTitle |
Taste, not a violation. Noted, not pursued |
Not a concern, checked and dismissed: Reduce Motion. The app has no custom animations (react-native-reanimated is only a transitive dependency of the navigation stack); transitions are native and honour the system setting for free.
Why the authenticated screens still were not swept
Credentials for the live demo deployment (x056.obscura.val.id) were available for this pass, and the server answers GET /auth/methods with {"modes":["local"],"passkeys":true} — so the password path is the one that renders, and no OIDC round-trip was needed. The blocker was not access, it was input.
Driving the simulator needs synthetic taps, and there is no supported path on this setup:
simctlhas no input verb at all —ui,push,pbcopy,openurl, but nothing that taps or types. (pbcopycan stage text on the device pasteboard, but pasting still needs a tap to focus a field.)- Xcode 27 ships no
Simulator.app; the UI isDeviceHub.app, so taps have to be posted to its window at host coordinates. I derived the device-pixel → host-point mapping by locating the same control in both asimctlscreenshot and a window capture (the horizontal scale cross-checked against an independently detected screen width, so the mapping was sound), but neitherSystem Events click atnor aCGEvent-based clicker produced any effect in the app. Most likely the process lacks macOS Accessibility permission, which makes synthetic HID events fail silently. mobile/e2e/'s two harnesses do not cover this: one drives the web export under Playwright (no iOS Dynamic Type), the other is Android (redroid + Maestro).
Granting Accessibility permission to the controlling terminal would unblock a scripted sweep. Failing that, this is a five-minute manual pass on a real phone — and the phone is required for the haptics anyway.
What this pass does and does not claim
It claims that every item marked Confirmed closed above was re-derived from primary evidence, and that the Dynamic Type findings were measured on a running build at each named content size rather than reasoned about.
It does not claim HIG conformance. Everything behind the sign-in wall — the document list, the ceremonies, the viewer, the workflow screens — was not exercised in this pass, because no backend was running. Their shared components (Button, Field, IconButton, Tabs, Segmented) were checked, and the Dynamic Type fix is in a shared component, but screen-level layout defects of the kind found in §2 can only be found by opening each screen. The next useful pass is a seeded backend plus the same content_size sweep across the authenticated screens.