think
16px
820px

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 closedminHeight: 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 closedminWidth: 44
H3 sheets with no dismiss source Confirmed closedheaderLeft on both share and upload
H4 FAB over the home indicator source Confirmed closedbottom: 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 CenteredScreen in components/ui.tsx: KeyboardAvoidingViewScrollView with contentContainerStyle={{ flexGrow: 1, justifyContent: 'center' }}. Keeps the centred look while content fits, degrades to an ordinary scroll when it does not. Adopted by server, 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.
  • ObscuraBrand gets maxFontSizeMultiplier={1.4} and flexShrink: 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:

  1. otp.tsx was 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 in otp.tsx with no feedback at all. The strongest act in the product was the one that said nothing.
  2. sign.tsx imported hapticSuccess and hapticWarning but never hapticError — so a failed signature was silent while a failed stamp buzzed, contradicting the module's own "plus the failures of those same acts".
  3. doPublish and doForward sat 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/language Segmented control 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". medium when a preset flings the box to a corner (big positional jump), light when tap-to-place drops it, and light when 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:

  • simctl has no input verb at all — ui, push, pbcopy, openurl, but nothing that taps or types. (pbcopy can stage text on the device pasteboard, but pasting still needs a tap to focus a field.)
  • Xcode 27 ships no Simulator.app; the UI is DeviceHub.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 a simctl screenshot and a window capture (the horizontal scale cross-checked against an independently detected screen width, so the mapping was sound), but neither System Events click at nor a CGEvent-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.