Opinionated iOS/Android UI + framework guidance for the most common capability areas a third-party PAM control's native module surfaces. In this track it is consulted by
/debug-extension(it MUST conform to a capability area's MANDATORY rows — e.g. a Done/Cancel affordance, active-tool feedback) and by the self-critique protocol (the adaptive-UI-primitive checklist)./design-native-extension-featureuses its own framework judgment for the capability→library mapping (grounded againstppmplugin-format.md§5/§5b) but the adaptive-UI primitives + MANDATORY interaction rules below still apply to the native module it specifies.This file is not exhaustive. For capability areas not listed here, fall back to general iOS/Android best practices and name the practice in the plan.
Maintenance: when an OS release changes the recommended pattern, update the entry here — the skill prompts don't hard-code framework choices.
For each operation in PRD §4 (top-level: name + purpose + pattern), the design skill:
- Classifies the operation into a capability area below — by reading the operation's purpose against the headings here.
- Reads the matching section for iOS + Android recommendations.
- Presents a recommendation block to the user with: framework + class choice, why (one-line rationale), min OS version, the key APIs, the decisions the user must make explicitly, the edge cases the implementation should handle, alternatives if the default doesn't fit.
- Gates with
AskUserQuestion: Accept / Adjust iOS / Adjust Android / Override entirely. - Writes the agreed spec to ARCHITECTURE §3.x — the per-operation implementation block that drives scaffold's code generation.
Scaffold reads §4.x and produces complete working code. No // TODO: implement markers. The agreed spec is what determines the output.
Status: preference, not requirement. Generators and editors should reach for these primitives first because they handle screen-size, font-scale, and overflow concerns automatically — the OS team has already solved the layout problem. If the design genuinely doesn't fit one of these (rare), a manual layout is fine — just be aware that you take on the responsibility for sizing across screen widths.
The dominant Android UI bug we've seen in extensions is: a horizontal LinearLayout with N wrap-content children overflows on small screens, and a primary action (Done) gets pushed off-edge. The fix isn't to teach the skill to measure — it's to use a component that the framework already handles measurement for. The adaptive primitives below have built-in overflow, distribution, or scrolling behavior so the "does it fit?" question doesn't arise.
| Component | Use it for | What it handles for you |
|---|---|---|
MaterialToolbar with menu items (menu/*.xml, app:showAsAction) |
Top nav bar with Cancel + Done + actions | Built-in overflow into ⋮ menu when buttons don't fit. Anchors navigationIcon left and menu actions right. Theming, accessibility semantics, back-arrow affordance — all free. |
BottomAppBar + FAB |
Bottom action bar with a primary action | Handles its own height, insets, and system-gesture areas. Pairs with FAB for the primary action. |
ConstraintLayout with chains + barriers |
General-purpose container with sibling relationships | Children resize / distribute per chain style (spread, spread_inside, packed); barriers handle dynamic content sizing. |
FlexboxLayout (com.google.android.flexbox:flexbox) |
Tool palettes, chip rows where content count varies | Wraps to next line when full; children can grow/shrink per flex factor. |
HorizontalScrollView wrapping a LinearLayout |
Tool palettes that must stay one row even when there are many tools | Gracefully scrolls horizontally when content exceeds width. |
MaterialButtonToggleGroup |
Mutually-exclusive mode selectors (Pen/Eraser, Draw/Pan, etc.) | Auto-tints the checked button per the Material 3 theme. Handles single-select / multi-select via app:singleSelection. |
RecyclerView with GridLayoutManager / a Compositional layout |
Lists / grids of items | Recycles views, handles arbitrary content size, supports adaptive column counts. |
- Raw horizontal
LinearLayoutwithWRAP_CONTENTchildren. Be aware: does not compress children when their combined width exceeds the parent. Consider this when you've genuinely got ≤2 controls and there's no overflow risk. FrameLayout,RelativeLayout— fine for stacked / absolute positioning where adaptive distribution isn't relevant.
| Component | Use it for | What it handles for you |
|---|---|---|
UINavigationBar with UIBarButtonItems |
Top nav bar | Truncates title with ellipsis, demotes items into overflow on compact size class, automatic theming. |
UIToolbar with .flexibleSpace items |
Distributed action bar | Auto-distributes items across width. |
UIStackView (.fillEqually, .equalSpacing, etc.) |
General horizontal/vertical arrangement | Children sized by distribution mode; respects intrinsic content size. |
UICollectionView with UICollectionViewCompositionalLayout |
Adaptive grids | Per-section layout adapts to size class. |
SwiftUI HStack { ... Spacer() ... } / Grid / ViewThatFits |
Layout in Swift code | Reactive sizing; ViewThatFits picks the first layout that fits. |
iOS UIKit components are opinionated by default, so the discipline is less load-bearing than on Android. If you're constraining custom views, the only common pitfall is constraining to view instead of view.safeAreaLayoutGuide (notch / home indicator). The preferred-primitive guidance is for awareness rather than as an active prevention measure.
Status: soft guideline, not a rule. Surfaced by the self-critique protocol as a suggestion when violated.
Even with an adaptive primitive that technically fits any number of children (e.g. MaterialToolbar overflow menu can hide ten actions behind ⋮), packing many controls into one horizontal row is poor UX:
- Cognitive load — users scan a row left-to-right looking for what they need. Five+ controls require more search effort and visual chunking; the eye doesn't land cleanly on the primary action.
- Discoverability — items hidden in an overflow menu (
⋮) are not discovered by most users. If an action is worth surfacing, it deserves a visible spot; if not, it probably doesn't belong in the toolbar at all. - Touch precision — at narrow widths, crammed controls become touch-target collisions. Each button gets less horizontal room, making accidental taps more likely.
Soft limit: ~3–4 logical control groups per horizontal row. Past that, redesign before reaching for an overflow menu.
A logical group is one user-facing affordance, not one widget. Examples:
- One
MaterialButtonToggleGroupcontaining N toggles = 1 group (it's one mode-selector affordance). - One
MaterialButton"Cancel" + spacer + oneMaterialButton"Done" = 2 groups. - A row with Cancel + Pen toggle + Eraser toggle + Clear + Undo + Redo + Done = 5 groups (the two toggles aren't a toggle group, and the rest are individual). Past the soft limit — redesign.
Redesign options when you're past the limit:
| Strategy | When to use |
|---|---|
| Multi-row layout | When the controls naturally split into navigation (Cancel/Done) + action surface (tools). Already the fallback for §1 drawing. |
| Contextual surfacing | Show some controls only when they're relevant (Undo/Redo appear only after the first stroke; Color picker only when Pen is active). Reduces resting-state density. |
| Drawer or sheet | Move secondary options (advanced settings, tertiary actions) into a bottom sheet or side drawer triggered by a single toolbar entry. |
| Grouping | Combine related individual controls into one logical group: turn "Pen |
Anti-pattern that this guideline catches: "I'll just add a ⋮ overflow menu" as a solution to a UI density problem. Overflow menus are appropriate for genuinely tertiary actions (Help, About, Settings). Using them to swallow primary or secondary actions because the toolbar got too crowded is a code smell — fix the design, not the layout.
Adaptive primitives are a default, not a mandate. Legitimate reasons to write a manual layout:
- The adaptive primitive's API genuinely doesn't expose what the design needs (e.g. you need a specific tool-palette visual that doesn't fit
MaterialToolbar's menu model). - The number of children is small and bounded (≤2), and the layout is trivial enough that adaptive behavior isn't needed.
- The capability area is non-standard and no adaptive primitive is obviously a fit.
When you deviate, the responsibility for sizing across screen widths shifts onto you. The self-critique protocol's Gate 3 will surface this as a concern (not a blocker) so the user can decide whether the deviation was intentional. The protocol will not force a switch — it'll just note the gap.
iOS — recommend PencilKit. Min iOS 14.0.
| Detail | Value / guidance |
|---|---|
| Primary class | PKCanvasView hosted in a dedicated UIViewController, presented modally |
| Tool picker | PKToolPicker in window-attached mode (iOS 14+; setVisible:forFirstResponder: then addObserver:). PencilKit's built-in tool picker handles mode switching (pen / eraser / lasso) with proper active-state UI, so you don't build that yourself. |
| Export | drawing.image(from: canvas.bounds, scale: 2.0) → UIImage → UIImagePNGRepresentation → base64 |
| Stroke count | canvas.drawing.strokes.count |
| Decisions for the user | Drawing policy (.anyInput vs .pencilOnly); ToolPicker visibility (visible vs hidden+custom UI); export scale (default 2.0); modal presentation style |
| Eraser behavior | Free with PencilKit's tool picker — supports both stroke-erase and rubber-pixel-erase modes; user toggles via the tool picker UI. |
| Edge cases | Rotation during capture (default: lock to portrait); empty submission (USER_CANCELLED vs empty result); background-app behavior |
| Alternatives | UIBezierPath + custom UIView (only for iOS < 14 compat — significantly more code, manual palm rejection, build your own tool picker) |
Android — recommend custom View with Canvas drawing. Min SDK 21. There is no PencilKit equivalent — you build the tools UI yourself.
| Detail | Value / guidance |
|---|---|
| Primary class | Custom View overriding onTouchEvent(MotionEvent); hosted in a dedicated Activity |
| Stroke model | Path per stroke, accumulated in a MutableList<Path> with parallel MutableList<Paint> for per-stroke styling |
| Stylus pressure | Read via MotionEvent.getPressure(); scale Paint.strokeWidth if the device reports > 0 (Samsung S-Pen, Wacom EMR support it; most phones return 1.0) |
| Hardware acceleration | setLayerType(LAYER_TYPE_HARDWARE, null) for low-latency drawing |
| Export | Render strokes onto a Bitmap via Canvas.drawPath; compress to PNG with Bitmap.compress(PNG, 100, ...); base64-encode |
| Toolbar — preferred | MaterialToolbar with menu items (see "Preferred adaptive UI primitives" at the top of this file). Cancel as navigationIcon (left), Done as a menu item with app:showAsAction="always" (right). Other actions (Clear, etc.) as menu items with app:showAsAction="ifRoom". The Toolbar handles overflow into the ⋮ menu automatically — no hand-rolled width juggling. Mode-toggle controls that need always-visible active state should live in a row below the toolbar, not as menu items (overflow menu hides active state). |
| Toolbar — manual fallback | A two-row LinearLayout (nav row + tool row) is acceptable when the design genuinely doesn't fit MaterialToolbar's menu model. Keep each row to ≤3 wrap-content children; if more, wrap the tool row in a HorizontalScrollView. |
| Done action | Recommended: include a Done action so the user has a way to submit. Without it, the only exit is Cancel — the operation can never resolve with a success result. The Done handler renders the bitmap and resolves the Promise with the base64 PNG. |
| Active-tool feedback | Recommended: when the user toggles between Pen / Eraser / etc., the active tool's button should visually indicate its state. Use a MaterialButtonToggleGroup with app:singleSelection="true" (radio-style toggle group). Toggled-on button uses theme's colorPrimaryContainer; toggled-off uses transparent. Without this, the user can't tell which mode is active. |
| Eraser semantics | Two options — pick one and document in ARCHITECTURE §3.: ① Stroke-erase: tap a stroke, remove the whole Path from the list. Cheap and predictable. ② Rubber-pixel-erase: PaintFlagsDrawFilter / PorterDuff.Mode.CLEAR. Erase only where the eraser moves. Matches user mental model of "rubber" but requires a different rendering pipeline (bitmap-backed rather than path-list). Recommend rubber-pixel-erase for v1+ extensions — better UX, matches iOS PencilKit behavior. |
| Decisions for the user | Pressure handling (uniform width vs pressure-scaled); active-tool toggle group visual style; eraser semantics (stroke vs rubber-pixel); Done button label ("Done" / "Save" / "Submit") |
| Edge cases | Rotation: android:screenOrientation="portrait" in the Activity manifest entry; process death: save draft to onSaveInstanceState; mixed stylus + finger input |
| Alternatives | Third-party libs (Android-SignaturePad, ScratchView) — pulls extra deps for negligible gain |
iOS — recommend PDFKit. Min iOS 11.0.
| Detail | Value / guidance |
|---|---|
| Primary class | PDFView hosted in a dedicated UIViewController |
| Loading | PDFDocument(url:) for file URIs; PDFDocument(data:) for base64 input |
| Decisions | Display mode (.singlePage, .singlePageContinuous, .twoUp, .twoUpContinuous); auto-scale (yes/no); page navigation gestures (default enabled) |
| Edge cases | Network-fetched URL (download to temp file, then load); password-protected PDF (PDFDocument.isLocked, call unlock(withPassword:)); huge PDFs (display progress) |
| Alternatives | WKWebView rendering the PDF — works for simple display but no PDF-specific affordances (text selection, annotations, search) |
Android — recommend PdfRenderer. Min SDK 21.
| Detail | Value / guidance |
|---|---|
| Primary class | android.graphics.pdf.PdfRenderer for page-by-page rendering to Bitmap |
| File source | Requires ParcelFileDescriptor opened on a seekable file — write the input to a temp file if it arrived as base64 |
| Display | Render each PdfRenderer.Page into a Bitmap, display via ImageView in a ViewPager2 for swipe navigation |
| Decisions | Page-render quality (default: screen-density pixels per point); whether to cache rendered pages in memory |
| Edge cases | Password-protected PDFs (PdfRenderer can't decrypt — need a third-party lib); huge PDFs (page-by-page rendering with LRU cache) |
| Alternatives | AndroidPdfViewer (third-party, more features incl. password support); WebView (basic, no good UX) |
iOS — recommend AVFoundation. Min iOS 13.0 for the recommended API surface.
| Detail | Value / guidance |
|---|---|
| Primary class | AVCaptureSession + AVCapturePhotoOutput hosted in a dedicated UIViewController with AVCaptureVideoPreviewLayer |
| Permission | NSCameraUsageDescription in Info.plist; AVCaptureDevice.requestAccess(for: .video) before starting session |
| Capture | photoOutput.capturePhoto(with: settings, delegate: self); receive AVCapturePhoto in delegate callback; convert via photo.fileDataRepresentation() |
| Decisions | Front vs back camera default; flash mode; capture format (JPEG vs HEIF); aspect ratio |
| Edge cases | PERMISSION_DENIED when user denied at OS level; orientation handling (AVCapturePhotoSettings.embedsDepthDataInPhoto and EXIF rotation); low-light auto-flash |
| Alternatives | UIImagePickerController (simpler API but less control, no live preview customization) |
Android — recommend CameraX. Min SDK 21.
| Detail | Value / guidance |
|---|---|
| Primary class | CameraX library (Jetpack); ImageCapture use case + PreviewView for preview |
| Permission | android.permission.CAMERA in manifest; runtime request via ActivityResultContracts.RequestPermission() (API 23+) |
| Capture | imageCapture.takePicture(outputFileOptions, executor, callback); receive OutputFileResults with the file URI |
| Decisions | Front vs back default; flash mode; quality preset; aspect ratio |
| Edge cases | Permission denied; device with no back camera; storage permission on API < 29 |
| Alternatives | Camera2 (lower-level, more control, more boilerplate); Camera (deprecated, avoid) |
iOS — recommend AVFoundation. Min iOS 13.0.
| Detail | Value / guidance |
|---|---|
| Primary class | AVAudioRecorder with AVAudioSession configured for .record category |
| Permission | NSMicrophoneUsageDescription in Info.plist; AVAudioSession.sharedInstance().requestRecordPermission(...) |
| Format | Default M4A (AAC); WAV available for higher quality with size cost |
| Decisions | Format (M4A vs WAV); sample rate (44.1 kHz default); max duration; live meter levels visible to user (yes/no) |
| Edge cases | Permission denied; interruption (phone call) — handle AVAudioSession.interruptionNotification; route change (headphones unplugged) |
| Pattern | Streaming if PRD wants live meter levels updating during recording; one-shot if just start → stop → return file |
Android — recommend MediaRecorder. Min SDK 21.
| Detail | Value / guidance |
|---|---|
| Primary class | MediaRecorder (audio-only profile) |
| Permission | android.permission.RECORD_AUDIO in manifest; runtime request |
| Format | OutputFormat.MPEG_4 + AudioEncoder.AAC for M4A; OutputFormat.THREE_GPP for compatibility |
| Decisions | Format; max duration; max file size; live amplitude metering via getMaxAmplitude() |
| Edge cases | Permission denied; audio focus loss (AudioManager.OnAudioFocusChangeListener); storage |
| Pattern | Same as iOS — streaming if meter updates needed |
iOS — recommend CoreLocation. Min iOS 13.0.
| Detail | Value / guidance |
|---|---|
| Primary class | CLLocationManager with desiredAccuracy = kCLLocationAccuracyBest; requestLocation() (one-shot API) |
| Permission | NSLocationWhenInUseUsageDescription in Info.plist; requestWhenInUseAuthorization() |
| Response | CLLocationManagerDelegate returns one fix via didUpdateLocations (single-element array) |
| Decisions | Accuracy (kCLLocationAccuracyBest, .tenMeters, .hundredMeters); timeout (no native — wrap in DispatchQueue.global().asyncAfter) |
| Edge cases | Permission denied/restricted (CLAuthorizationStatus); airplane mode (didFailWithError with kCLErrorLocationUnknown); timeout |
| Pattern | One-shot — requestLocation() produces exactly one fix |
Android — recommend FusedLocationProviderClient. Min SDK 21 (with Google Play Services).
| Detail | Value / guidance |
|---|---|
| Primary class | FusedLocationProviderClient from com.google.android.gms:play-services-location |
| Permission | android.permission.ACCESS_FINE_LOCATION (or _COARSE_); runtime request |
| Method | client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, cancellationToken) |
| Decisions | Priority (HIGH_ACCURACY, BALANCED_POWER_ACCURACY, LOW_POWER); whether to allow stale cached location |
| Edge cases | Permission denied; Google Play Services unavailable (rare on first-party scenarios, but real on Huawei devices) → fall back to LocationManager; airplane mode |
iOS — recommend LocalAuthentication. Min iOS 11.0.
| Detail | Value / guidance |
|---|---|
| Primary class | LAContext with evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, ...) |
| Permission | NSFaceIDUsageDescription in Info.plist (required for Face ID; not for Touch ID) |
| Decisions | Policy (biometricsOnly vs biometricsOrPasscode); fallback button text |
| Edge cases | No biometric enrolled (LAErrorBiometryNotEnrolled); biometry lockout after 5 failed attempts; user cancels (LAErrorUserCancel) |
| Pattern | One-shot — single auth attempt resolves Promise with success/failure |
Android — recommend BiometricPrompt. Min SDK 28 (API 28+); fallback to FingerprintManager for older.
| Detail | Value / guidance |
|---|---|
| Primary class | androidx.biometric.BiometricPrompt (Jetpack — backports to API 23) |
| Permission | android.permission.USE_BIOMETRIC (API 28+) or USE_FINGERPRINT (older); no runtime request needed |
| Decisions | Allowed authenticators (BIOMETRIC_STRONG, BIOMETRIC_WEAK, DEVICE_CREDENTIAL); negative button text |
| Edge cases | No biometric enrolled; hardware not present; user cancels; lockout (5 fails) |
iOS — recommend Vision framework + AVCaptureSession. Min iOS 13.0.
| Detail | Value / guidance |
|---|---|
| Primary class | VNDetectBarcodesRequest from Vision framework, fed frames from AVCaptureVideoDataOutput |
| Permission | NSCameraUsageDescription (same as camera) |
| Symbols supported | All AVMetadataObject types (.qr, .ean13, .pdf417, etc.) — pass to VNDetectBarcodesRequest.supportedSymbologies |
| Decisions | Which symbol types to scan (QR only? all 1D + 2D?); scan region (full screen vs centered rect); haptic feedback on detection |
| Edge cases | Permission denied; no camera (simulator); multiple codes in frame |
| Pattern | Streaming if PRD wants each detection emitted as it happens; one-shot if "scan once and dismiss" |
Android — recommend ML Kit Barcode Scanning. Min SDK 21.
| Detail | Value / guidance |
|---|---|
| Primary class | BarcodeScanning.getClient(...) from com.google.mlkit:barcode-scanning; feed frames from CameraX's ImageAnalysis use case |
| Permission | CAMERA (same as camera) |
| Symbols | Barcode.FORMAT_QR_CODE, FORMAT_EAN_13, etc. — pass to BarcodeScannerOptions.Builder.setBarcodeFormats(...) |
| Decisions | Same as iOS |
| Edge cases | Same as iOS |
For capabilities outside the list above (e.g. NFC, MapKit, AR, file picker, share sheet), the design skill should:
- Search its own framework knowledge for the standard iOS + Android idioms.
- Compose a recommendation block in the same format used above.
- Surface the same decision points (key APIs, decisions for the user, edge cases, alternatives).
- Flag explicitly that this capability isn't in
framework-recommendations.mdyet — invite the user to confirm or supply expertise.
If the user accepts and the resulting extension ships, the lesson belongs back in this file as a new section.
- Prefer first-party OS frameworks over third-party libs unless the third-party lib is materially better. PAM extensions ship as part of the host app's binary; every extra dependency is a long-term maintenance cost.
- Surface explicit decisions to the user — never let the LLM silently pick between two reasonable options. The ARCHITECTURE §3.x block exists exactly to make these decisions visible and reviewable.