think
16px
820px

Obscura Mobile — Apple Human Interface Guidelines audit

Date: 2026-07-27 · Build: main @ 132e1c9 · Target: iOS 15.1+, iPhone (portrait, supportsTablet: true)

Status — 2026-07-27, after the fix round (2686ec2, +haptics)

Finding Status
H1 unused camera + microphone permissions Closed — introspection resolves 0 camera/mic keys
H2 boilerplate purpose strings Closed — Face ID and photo library state their purpose
§2 four sub-44pt controls Closed — the sweep went 17 → 2, and both remaining are the documented Switch false positive
H3 sheets with no dismiss control Closed — Done on Share, Cancel-with-confirm on Upload
H4 FAB over the home indicator Closed — bottom safe-area inset applied
H5 placeholder contrast Closed — 2.38:1 → 5.02:1 light, 3.01:1 → 4.56:1 dark
H6 no haptics Closed in code, unverified on deviceexpo-haptics wired to the commit/failure moments; the feel needs hardware
H7 Dynamic Type at AX sizes Open — device-only
Header hit area on the native nav bar Open — device-only confirmation
H8 large titles, supportsTablet, expo-glass-effect Open by choice — product decisions, not defects

This is not a compliance certificate. HIG conformance is not a binary a static audit can grant; what this table says is that every finding this audit could measure is now measured as fixed, and the rest are named honestly as open.


Scope and evidence — read this first

This audit is not a device pass. There is no Apple toolchain on this box, so nothing here was observed running on iOS. What it is built from:

  1. Native configurationapp.json, the generated ios/Obscura/Info.plist, the Expo config plugins, and the declared dependency set. This evidence is exact.
  2. Source inspection — navigation options, presentation styles, gesture config, alert styles, safe-area usage, the design tokens.
  3. Measured geometry — every interactive element across 12 screens, measured in the web export at iPhone 13 Pro logical size (390×844). Layout written in React Native primitives measures the same on device except where a control maps to a UIKit native view.

What that last exception means in practice: one measurement in the sweep is a false positive and is excluded below — Switch measures 40×20 in the web export but renders as a native UISwitch (51×31 with an OS-extended hit area) on device. Anything else that maps to native chrome (nav bar, tab bar, alerts, sheets) is flagged as needs device confirmation rather than asserted.

Not covered at all (needs a Mac + device): Dynamic Type at accessibility sizes, VoiceOver rotor navigation, haptic feel, ProMotion/scroll behaviour, Live Text, Stage Manager on iPad.


1. Correction to this audit's first version

The "photo library crash" reported in the first version of this document was wrong. It is corrected here rather than quietly deleted, because the reasoning error is worth recording.

I grepped the committed ios/Obscura/Info.plist and found only one usage key. But ios/ is gitignored CNG output — a stale artifact generated 2026-07-13, while expo-image-picker was added 2026-07-14. It does not describe what a build produces. Running the plugin pipeline read-only (expo config --type introspect, which works fine on Linux) shows what actually resolves:

NSFaceIDUsageDescription     = 'Allow $(PRODUCT_NAME) to use Face ID'
NSPhotoLibraryUsageDescription = 'Allow $(PRODUCT_NAME) to access your photos'
NSCameraUsageDescription     = 'Allow $(PRODUCT_NAME) to access your camera'
NSMicrophoneUsageDescription = 'Allow $(PRODUCT_NAME) to access your microphone'

expo-image-picker's config plugin is auto-applied from the installed package even though it is not listed in plugins. There is no crash. The real findings in this area are different, and one of them is arguably worse.

H1. Camera and microphone permissions are declared but never used (HIGH)

expo-image-picker's defaults inject NSCameraUsageDescription and NSMicrophoneUsageDescription. A search of src/ finds no camera call, no launchCameraAsync, no audio API — and "no camera / document scanning" is an explicit, locked product decision for this app.

Shipping permission requests you never exercise is a documented App Review problem (Apple asks why access is requested that the app does not use) and it populates the App Privacy nutrition label with data types you do not touch — for a document-security product, claiming camera and microphone access you never use undercuts the whole pitch.

Fix: register the plugin explicitly and switch the unused ones off:

["expo-image-picker", {
  "photosPermission": "Obscura uses your photo library so you can import a signature image.",
  "cameraPermission": false,
  "microphonePermission": false
}]

H2. All four usage strings are Expo boilerplate (HIGH — review risk)

Every resolved string is the framework default ("Allow $(PRODUCT_NAME) to…"). Guideline 5.1.1 asks the purpose string to say why, and here the reasons are specific and strong: Face ID unlocks the app and confirms that a signature is yours; the photo library exists solely to import a signature image. Generic defaults are a routine rejection, and they are also the user's first impression of the app's seriousness.

Fix: explicit faceIDPermission / photosPermission strings, in both shipped languages.

2. Measured HIG violations — the 44×44pt minimum

HIG: "Give all controls a hit target that measures at least 44×44 points." The sweep found four genuine violations (the Switch result is excluded per the scope note).

Control Measured Screens Why it matters
Placement presets (Bottom left / Bottom right / Top right) 82×25 sign, e-Meterai, stamp paddingVertical: spacing[2] (4pt). These sit inside the legal ceremony — a mis-tap moves where a signature or a tax stamp lands
Header avatar button 62×30 every tab screen headerRight; has horizontal padding but no vertical, so it inherits the 30pt avatar height. Needs device confirmation — react-navigation may extend the hit area to the 44pt bar height natively
Incognito toggle 22×24 AI An Ionicons with onPress directly — no Pressable padding, no hitSlop
AI tab 40×46 document Tall enough, but the short label leaves it 4pt under on width

Everything else measured ≥44 in both dimensions: buttons 44, fields 45, chips 44 (fixed this round), action icons 59, compact rows 48.


3. Other HIG findings

H3. Modal sheets have no explicit dismiss control (MEDIUM)

Share and Upload are presented with presentation: 'modal' (a sheet on iOS) and neither declares a headerLeft Cancel. Dismissal relies entirely on the swipe-down gesture. HIG asks a sheet to provide a visible way out; a swipe is also awkward under VoiceOver and impossible to discover for a first-time user. Upload additionally has unsaved state, where HIG expects an explicit Cancel that can confirm before discarding.

H4. The FAB ignores the home indicator on pushed folder screens (MEDIUM)

repository-list.tsx positions the floating "+" at bottom: spacing[6] (24pt) with no safe-area inset. On the Browse tab that is fine — the tab bar sits below it and owns the inset. On a pushed /folder/[id] screen there is no tab bar, so the button lands 24pt from the physical bottom, inside the 34pt home-indicator region HIG reserves.

H5. Placeholder text fails contrast in light mode (MEDIUM)

textPlaceholder #a8a8a8 on layer01 #ffffff = 2.38:1, against the 4.5:1 requirement. Dark mode is 3.01:1 — large-text-only. Placeholder copy carries real instruction here (dms.company.co.id, budi@partner.co, @vendor.io).

Full palette check, for the record — everything else passes: body 18.1:1, secondary 7.8:1, links 5.0:1, primary/danger button labels 5.0:1, tertiary 4.6:1. Disabled text measures 1.94:1 but WCAG explicitly exempts inactive controls, so it is not counted as a failure.

H6. No haptic feedback anywhere (MEDIUM — iOS expectation) — FIXED IN CODE

Resolved after this audit: expo-haptics now backs lib/haptics.ts, wired to the
moments where something irreversible happens to a document — signed, e-Meterai affixed,
stamp affixed, approval/rejection recorded — plus the failures of those same acts, and a
warning pattern for "the provider is still working on it". Navigation, chips and list
taps stay deliberately silent: a buzz on every tap stops carrying meaning. The feel
itself still needs a device.

Original finding:

expo-haptics is not a dependency and no haptic call exists in the app. HIG treats haptics as part of the feedback vocabulary on iPhone, and this app's defining moments — a signature committed, an e-Meterai affixed, an approval recorded, a biometric confirm — are exactly the events iOS users expect to feel. Their absence reads as "web app in a wrapper", which is also the impression App Review forms.

H7. Dynamic Type is unbounded and untested (MEDIUM — unverified)

maxFontSizeMultiplier is set nowhere (0 usages). Text scales freely while Button, Chip and compact Row carry fixed minHeight: 44 with fixed padding, so at the accessibility sizes (AX1–AX5) labels will overflow or clip. Supporting Dynamic Type is a HIG requirement, not a nicety — but the failure mode here cannot be confirmed without a device, so treat this as a risk to test rather than a proven defect.

H8. Small platform-idiom gaps (LOW)

  • No large titles. HIG recommends headerLargeTitle for top-level destinations (Inbox, Archive). Currently every screen uses the compact bar. Taste, not a violation.
  • expo-glass-effect is an unused dependency — declared in package.json, zero references in src/. It ships weight for nothing; either use it for the iOS 26 chrome or drop it.
  • iPad: supportsTablet: true while the layout is phone-only (no split view, no multi-column). If iPad is not a target, setting this to false avoids being reviewed against iPad HIG.

What already follows the guidelines

Worth stating plainly, because these were deliberate and they are right:

  • Destructive confirmations are native and correctly styledAlert.alert with style: 'destructive' and style: 'cancel' (lib/confirm.ts), which is exactly the HIG pattern.
  • Appearance — a complete dark theme plus a user override (Auto / Light / Dark), rather than forcing either.
  • Back-swipe is intact — no screen disables gestureEnabled.
  • Portrait lock is declared, matching a one-handed document app.
  • Safe-area insets are respected where content is bottom-anchored (the sticky action bars, the ceremony scroll views).
  • Native tab bar and nav bar via react-navigation, not a custom reimplementation.
  • A minimum-44pt Button and Chip, after this round's fixes — the component kit now defends the floor by default.
  • Permission-adjacent honesty — screenshot protection runs silently rather than narrating itself, which is the iOS convention.

Priority

# Fix Severity Effort
1 Drop the unused camera + microphone permissions (H1) High 5 min
2 Real purpose strings for all four usage keys (H2) High 15 min
3 Four sub-44pt controls (§2), presets first — they're in the ceremony High ~30 min
4 Cancel button on the Share and Upload sheets (H3) Medium ~20 min
5 FAB safe-area inset on folder screens (H4) + placeholder contrast (H5) Medium ~15 min

H6 (haptics) is a half-day including choosing the right feedback per act. H7 (Dynamic Type) needs a device before it can be closed honestly.


Appendix — what actually needs a Mac

The split matters for planning, so it is worth being exact. Most of this list does not need the Apple toolchain.

Fixable and verifiable on Linux (no Mac):

  • Everything in §1. expo config --type introspect runs the real plugin pipeline and prints the resolved Info.plist, so a permission change can be proven, not just written. That is how the correction above was found.
  • Every sub-44pt control in §2. These are React Native layout values; the web export at 390×844 measures them exactly, which is how they were found in the first place.
  • H3 (sheet Cancel buttons), H4 (FAB safe-area inset), H5 (placeholder contrast — arithmetic), H8 (unused dependency, supportsTablet).

Needs a Mac to build, but the code can be written here:

  • H6 haptics. expo-haptics is a new native module, so the first build after adding it needs pod install. The integration itself is ordinary TypeScript.

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 to the full nav bar (§2, the avatar row).
  • VoiceOver rotor order, haptic feel, ProMotion scrolling.

The practical division: land the fixes here, and use the Mac for the build-and-confirm pass rather than for the work itself.