Vancetope Mobile-UI — Specification

Status: v1 Initial Draft. Binding for the development of the mobile app under repos/vance/client/packages/vance-fingers/.

Prerequisite: @vance/shared is platform-neutral. The Mobile-Spec assumes this — it references configurePlatform, KeyValueStore, RestConfig, and the separated Speech modules.

1. Goals and Scope

Vancetope will receive a native mobile app for iOS and Android as a complement to the existing clients (vance-cli, vance-foot, vance-face). The mobile app is a standalone client with a significantly narrower range of functions than the Web-UI.

What the Mobile App is:

  • Three main views: Inbox, Chat, Documents (read-only / simple).
  • Voice control in Chat (Push-to-Talk STT + optional TTS Voice Output).
  • “What do I need to decide now?” — Inbox as the primary entry point, as it is the main use case on the go.
  • Bearer JWT Auth (no cookie concept).

What the Mobile App explicitly is not (v1):

  • No Sessions/Process/Settings/Recipes/Projects editor. These belong on a large display.
  • No Document editing. Documents are read-only — including inline text. Structured Kinds (Mindmap/Graph/Sheet) are rendered as a Plain-Text fallback.
  • No Push Notifications in v1. Slot is open, implementation follows in v2 (see §11).
  • No Offline capability. WS-only Chat is not compatible with offline; Inbox/Documents could REST-cache, but that is v2.
  • No Cross-Device State Synchronization beyond the server state.
  • No Tablet-specific layouts. iPhone and Android phone form factor (Portrait). Tablet support comes with re-layout in v2.

The deliberate asymmetry to the Web-UI: Mobile only shows what is useful on the go. Those at a laptop use vance-face.

2. Architecture

2.1 Repository Layout

The mobile app is located in the same pnpm workspace as the Web-UI, as a fourth package alongside generated, shared, and vance-face:

repos/vance/client/                          (Workbench-Symlink: vance-wb/client_web)
├── pnpm-workspace.yaml
├── package.json
├── tsconfig.base.json
└── packages/
    ├── generated/        @vance/generated
    ├── shared/           @vance/shared           (platform-neutral after reorg)
    ├── vance-face/       @vance/vance-face       (Web-UI)
    └── vance-fingers/     @vance/vance-fingers     (NEW)

Workspace Tool: pnpm. Mobile runs in the same workspace so that @vance/shared can be referenced via workspace:* and the build reorder works via pnpm filters (pnpm --filter @vance/shared buildpnpm --filter @vance/vance-fingers start).

Reason against a separate repo: The TypeScript contracts (@vance/generated and @vance/shared) must remain synchronized. A separate repo boundary would force version pinning and cross-repo sync effort without added value.

2.2 Package Responsibilities

Package Scope Content
@vance/generated DTOs from Java TypeScript interfaces, generated by the Maven plugin. No logic.
@vance/shared Client Logic (UI- and platform-agnostic) JWT/Token Storage via adapter, REST client, WS Connection Manager, Speech Preferences. No Vue, no React, no Tailwind.
@vance/vance-face Web-UI Vue 3 + Vite + Tailwind + DaisyUI. Multi-page app, one HTML entry per editor.
@vance/vance-fingers Mobile-UI React Native + Expo + NativeWind. Single app with tab navigation.

2.3 Dependency Rules (strict)

Package May depend on
@vance/generated nothing (no runtime deps)
@vance/shared @vance/generated
@vance/vance-face @vance/shared (transitive @vance/generated) — no direct REST/WS imports
@vance/vance-fingers @vance/shared (transitive @vance/generated) — no direct REST/WS imports

@vance/vance-fingers must not contain direct fetch or WebSocket code. All network calls go through @vance/shared. This ensures @vance/shared remains the sole location for Auth, Reconnect logic, and URL construction.

2.4 Stack

Component Technology Rationale
Language TypeScript Consistent with Web, full re-use of DTOs
Framework React Native 0.76+ Established, large ecosystem selection
Toolchain Expo (managed workflow) No Xcode/Gradle setup on dev machines, EAS Build for store releases, OTA updates for bug fixes
Navigation React Navigation 7 Standard, well-documented, Bottom-Tabs + Stack combined
Styling NativeWind 4 Tailwind classes for RN — consistency with Web Tailwind scale
State Zustand + TanStack Query Zustand for local UI state, TanStack Query for REST caching/invalidation. Pinia is Vue-specific, no re-use.
Storage (Tokens) expo-secure-store Keychain (iOS) / Keystore (Android)
Storage (Prefs) @react-native-async-storage/async-storage Fast, non-sensitive KV store
TTS expo-speech Native AVSpeechSynthesizer / Android TTS
STT @react-native-voice/voice Native Speech Recognition (iOS Speech / Android SpeechRecognizer)
HTTP/WS @vance/shared (internal: fetch + WebSocket, both RN-native) No polyfills needed
Icons @expo/vector-icons (Material/Feather) Distributed with Expo

Deliberately not in the stack:

  • React Native Paper / Tamagui / NativeBase: too large and opinionated style, would undermine NativeWind+Tailwind consistency.
  • Reanimated for Chat animations: not v1. Token streaming is sufficient with React state.
  • Expo Notifications: prepare, but do not use (see §11).

2.5 App Identity

  iOS Android
Bundle ID / Package de.mhus.vance.mobile de.mhus.vance.mobile
App Name Vancetope Vancetope
Min Version iOS 15 Android 8 (API 26)

3. View Inventory v1

The mobile app is a single app with Bottom-Tab Navigation (three tabs) and a Login stack above it.

View Tab / Stack Channel Live Purpose
Login Stack over tabs (modal-blocking) REST Tenant + Username + Password. “Remember user” saves Tenant+Username in prefsStore, no password.
Inbox List Tab 1 (default) REST List of current user’s PENDING items. Filter via header picker (Tag, Criticality). Pull-to-refresh.
Inbox Detail Stacked under Inbox tab REST Item payload + type-specific action bar (APPROVAL, DECISION, FEEDBACK …). Reply / Archive / Dismiss / Delegate.
Chat Picker Tab 2 REST List of active Sessions (grouped by Project). Tap opens Live Chat.
Chat Live Stacked under Chat tab WS + REST (History) Session Chat with streaming, voice in/out, composer, Process progress display.
Documents List Tab 3 REST List with filter by Project, Path-Prefix, Kind. Pull-to-refresh.
Document Detail Stacked under Documents tab REST Read-only content: Inline text is rendered; files (image, PDF) with expo-image / react-native-pdf. Structured Kinds (Mindmap/Graph/Sheet) as JSON plain-text fallback.

Consequence: The WS module from @vance/shared is only imported by the Chat-Live screen. Other screens exclusively use REST (TanStack-Query caches).

3.1 Tab Bar

Index Label Icon Badge
0 Inbox mail Number of PENDING items from last REST request (see §3.2)
1 Chat chat-bubble
2 Docs folder

The Inbox badge number is read from the inbox-pending-summary WS frame as soon as the WS connection is open when the Chat tab is opened. If the user is on another tab and no WS is active, the badge falls back to a REST poll value (every 60s, disabled in app background — see §9).

4. REST/WS Division

The Mobile App follows the same doctrine as the Web-UI: WS is the primary backend channel, REST is convenience. For v1, live updates are exclusively in Chat. Inbox updates in v1 are fetched manually via pull-to-refresh; live updates will move to v2 along with push notifications.

4.1 REST Endpoints (all tenant-scoped: /brain/{tenant}/...)

View Endpoint Purpose
Login POST /access/{username} with requestCookies: false Token-in-body path. Tokens land in Keychain via secureStore.
Login (Refresh) POST /refresh Body refresh: Refresh Token from secureStore → new Access Token back
Logout POST /logout Server-side invalidate, then delete tokens locally
Inbox List GET /inbox?status=PENDING&assignedTo=me&tag=... TanStack-Query, Stale-Time 30s, manual refresh via Pull-to-Refresh
Inbox Detail GET /inbox/{id} If not sufficiently populated from list cache
Inbox Tags GET /inbox/tags Filter picker options
Inbox Answer POST /inbox/{id}/answer with AnswerPayload Send answer
Inbox Archive POST /inbox/{id}/archive  
Inbox Dismiss POST /inbox/{id}/dismiss  
Inbox Delegate POST /inbox/{id}/delegate  
Chat History GET /sessions/{id}/messages?limit=200 When opening a Session, before WS-Connect
Sessions List GET /sessions Chat picker data source
Documents List GET /documents?projectId=...&pathPrefix=...&kind=... Pageable
Document Detail GET /documents/{id} Inline content in body, if present
Document Content GET /documents/{id}/content Storage-backed Files (images, PDF). Authorization header is injected by RestClient from secureStore — no ?token= hack on Mobile.

4.2 WebSocket Frames

Mobile opens a WS connection only when entering the Chat-Live screen, closes it when leaving. One connection per Session (analogous to the Web-UI).

Connection Profile: mobile (see specification/recipes.md §6a for profile block selection on server side).

Direction Frame Purpose
→ Brain process-create / process-steer / process-pause / process-resume / process-stop Standard lifecycle
→ Brain session-create / session-resume Session bootstrap on entry
← Brain welcome Connection handshake
← Brain chat-message-stream-chunk Token streaming, buffered in streamingDrafts map
← Brain chat-message-appended Commit, replaces stream buffer
← Brain process-progress Status side-channel, right-panel slot or Mobile: compact inline banner above the composer
← Brain inbox-pending-summary On welcome — populates the Inbox tab badge
← Brain inbox-item-added / inbox-item-updated Live pinging of the Inbox tab badge

Auth via WS: Bearer JWT in the ?token= query parameter. The server accepts this according to specification/websocket-protokoll.md §2 — there is no cookie concept on Mobile.

4.3 Stream Buffer Pattern

Identical to the Web-UI (see specification/web-ui.md §6.5):

// vance-fingers/src/screens/chat/useChatStream.ts
const streamingDrafts = new Map<string, string>(); // thinkProcessId → buffered chunks

ws.on('chat-message-stream-chunk', (data: ChatMessageChunkData) => {
  streamingDrafts.set(data.thinkProcessId, (streamingDrafts.get(data.thinkProcessId) ?? '') + data.chunk);
  // re-render bubble for this process with the buffered text
});

ws.on('chat-message-appended', (data: ChatMessageAppendedData) => {
  streamingDrafts.delete(data.thinkProcessId);
  // append finalized message; if id is already in history (REST snapshot), dedupe
});

5. Auth Flow

5.1 Login

User opens app
  ↓
Boot reads `prefsStore.get('vance.identity.tenantId')` and refresh-token from `secureStore`
  ↓
If both present and refresh succeeds → land on Inbox tab
If anything missing/expired → push Login screen modally

Login Request:

const body: AccessTokenRequest = {
  password,
  requestRefreshToken: true,
  requestCookies: false,         // ← Mobile-Flag: no cookies, tokens in body
  includeWebUiSettings: false,   // ← Mobile does not need webui.*
};
const r = await fetch(`${baseUrl}/brain/${tenant}/access/${user}`, { method: 'POST', body: JSON.stringify(body), headers: {...} });
const resp = await r.json() as AccessTokenResponse;

await secureStore.set('vance.auth.accessToken', resp.accessToken);
await secureStore.set('vance.auth.refreshToken', resp.refreshToken);
await prefsStore.set('vance.identity.tenantId', resp.tenantId);
await prefsStore.set('vance.identity.username', resp.username);

5.2 Refresh

RestConfig is set to authMode: 'bearer'. restClient.ts injects Authorization: Bearer ${accessToken} from secureStore. On 401, the client attempts a refresh:

const r = await fetch(`${baseUrl}/brain/${tenant}/refresh`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ refreshToken: await secureStore.get('vance.auth.refreshToken') }),
});
if (!r.ok) onUnauthorized(); // navigation.replace('Login')
const resp = await r.json() as RefreshTokenResponse;
await secureStore.set('vance.auth.accessToken', resp.accessToken);
// retry the original request once

onUnauthorized on Mobile is a navigation callback to the Login screen, not window.location.href.

5.3 Logout

await fetch(`${baseUrl}/brain/${tenant}/logout`, { method: 'POST', headers: bearer() });
await secureStore.remove('vance.auth.accessToken');
await secureStore.remove('vance.auth.refreshToken');
await prefsStore.remove('vance.identity.tenantId');
await prefsStore.remove('vance.identity.username');
navigation.replace('Login');

5.4 Biometrics (optional, v1.1)

expo-local-authentication wraps the app start: If a refresh token is present, the app prompts for Face ID / Touch ID / Fingerprint once before reading the token from secureStore. Pure UX polish, the token itself is already hardware-protected. By default off in v1, can be enabled in App Settings.

6. Voice Strategy

Voice control in Chat is the primary differentiating feature of the Mobile App.

6.1 STT (Speech-to-Text) — Push-to-Talk

Library: @react-native-voice/voice (native Speech Recognition).

Pattern: Push-to-Talk via hold button next to the composer. No always-listening — saves battery, avoids permission friction, clear user mental model (“when I hold the button, it listens to me”).

Flow:

  1. User holds the mic button (onPressInVoice.start(lang)).
  2. While speaking, partialResults are displayed as gray preview text in the composer.
  3. User releases (onPressOutVoice.stop()).
  4. finalResults are written into the composer as plain text.
  5. User can edit, then taps Send (or uses auto-send from settings).

Language: Read from @vance/shared/speech/preferences.ts getSpeechLanguage(). Default is BCP-47 from Localization.getLocales()[0].languageTag.

Permissions:

  • iOS: NSSpeechRecognitionUsageDescription and NSMicrophoneUsageDescription in app.jsonexpo.ios.infoPlist. On the first tap of the mic button, Voice.start() automatically triggers the OS prompt.
  • Android: RECORD_AUDIO. Voice.requestPermissions() on first tap.

Fallback: If STT is unavailable (permission denied, offline on iOS — on-device recognition is possible since iOS 13, but the server variant requires network), the mic button is grayed out. Tap shows a toast “Speech recognition unavailable — check Settings”.

6.2 TTS (Text-to-Speech) — Speaker for Assistant Messages

Library: expo-speech (native AVSpeechSynthesizer / Android TTS).

Toggle: Speaker icon in the Chat topbar. Persisted in prefsStore (vance.speakerEnabled) — same convention as Web.

Pattern:

  • On incoming chat-message-appended with role=ASSISTANT and speakerEnabled === true:
    • Text is filtered by stripMarkdown() from @vance/shared/speech/markdown.ts.
    • expo-speech.speak(text, { language, rate, voice }) starts playback.
  • When leaving the Chat-Live screen: expo-speech.stop().
  • Stream chunks are not read aloud — TTS only starts on appended. (The stream would be too fragmented for TTS, and the audio would stop/restart per chunk.)

Settings:

  • Rate (0.5–2.0) — prefsStore vance.speechRate, shared with Web.
  • Voice — expo-speech.getAvailableVoicesAsync() provides a list per language; user selects once. URI is stored in prefsStore vance.speechVoiceUri. On resolve, we fall back to the platform default if the stored voice no longer exists (OS updates can remove voices).

Background Audio: If the speaker is on and the screen locks, reading should continue. iOS requires the audio background mode for this in app.jsonexpo.ios.infoPlist.UIBackgroundModes: ['audio']. On Android, this is on by default as long as the process is alive.

6.3 Voice Settings Screen

A dedicated settings sub-screen in the User Menu (Topbar Avatar) bundles all voice options — analogous to the Speech popover in the Web-UI:

  • Speaker on/off
  • Voice picker (list of native voices, filtered by current language)
  • Rate slider
  • Volume — on Mobile not in app settings, but system volume. (expo-speech does have a volume parameter, but this is confusing on Mobile.)
  • Language picker (BCP-47, same list as Web)

7. UI Consistency

To ensure the Mobile App remains visually coherent with the Web-UI, the following applies, analogous to the Web-UI doctrine:

7.1 Mandatory Shell — <MobileShell>

Every top-level screen renders via <MobileShell>. Header (title + optional back action + optional trailing actions), body, optional footer (e.g., composer in chat). Geometry centralized, no one builds their own <View> header.

<MobileShell title="Inbox" trailing={<MobileShell.IconButton icon="filter" onPress={openFilter} />}>
  <InboxList />
</MobileShell>

7.2 Mandatory Components

Component library in vance-fingers/src/components/ with V-Prefix (same convention as Web — facilitates cross-reading, even if implementations differ):

Component Web Counterpart Purpose
<MobileShell> <EditorShell> Top-level layout with header/body/footer
<VButton variant size loading> <VButton> Variants: primary | secondary | ghost | danger | link
<VInput> <VInput> Single-line input with label/help/error
<VTextarea> <VTextarea> Multiline input
<VSelect> <VSelect> Dropdown — on Mobile as ActionSheet (@expo/react-native-action-sheet)
<MarkdownView> <MarkdownView> Read-only Markdown — react-native-markdown-display
<VAlert> <VAlert> Banner (info/warning/error/success)
<VEmptyState> <VEmptyState> Icon + Headline + Body + Action
<VToast> <VToast> Auto-dismiss Notification — Position: above the tab bar
<VLoading> <VLoading> Skeleton in lists, spinner for inline actions
<VBadge> <VBadge> Tab-bar badge, item counts
<VDataList> <VDataList> FlatList wrapper with Pull-to-Refresh + Empty-State + Pagination

NativeWind classes outside this directory are allowed — unlike on Web (btn/input/…), NativeWind has no semantic DaisyUI classes, but pure Tailwind atomics. Layout classes (flex-1, gap-2, p-4) are used directly by each screen. Style atomics (bg-primary, text-base, rounded-lg) also — as long as the style works with the theme from @vance/shared/theme.ts (see §7.4).

7.3 Enforcement

  • Code Review Block: Screens that build their own header / own buttons / own modal layout instead of the primitives will not be merged. If a new UI pattern is needed, it is first added to src/components/ and then used.
  • /preview-screen: A dedicated dev-only screen (hidden behind a long-press on the app logo in Login) that shows every primitive in every state. Maintained when adding a new primitive.

7.4 Theme

@vance/shared/theme.ts (new module, platform-neutral) exports a canonical color palette in light/dark:

export const theme = {
  light: { primary: '#2563eb', surface: '#ffffff', text: '#0f172a', ... },
  dark:  { primary: '#3b82f6', surface: '#0f172a', text: '#f1f5f9', ... },
};

Web reads this in tailwind.config.js, Mobile in tailwind.config.js of NativeWind. This keeps both platforms color-synchronized — changing primary changes it in one place.

Theme Selection: System default (useColorScheme() from react-native). Toggle in App Settings (no topbar toggle like on Web — on Mobile, theming is a settings concern, not a workflow concern).

Typography: System fonts (San Francisco / Roboto). No Inter/JetBrains-Mono on Mobile — custom fonts are more expensive on Mobile (bundle size, font loading flicker), and the Web Inter hardly differs from the system font.

8. Build & Deployment

8.1 Dev Workflow

# Workspace Boot
cd repos/vance/client && pnpm install

# Mobile Dev with Expo Go (iOS Simulator / Android Emulator / physical device via QR)
pnpm --filter @vance/vance-fingers start

# Brain Dev in parallel
cd vance/vance-brain && mvn spring-boot:run

expo-cli loads app.json with extra.brainUrl from an app.config.ts function that distinguishes between dev and prod builds:

// app.config.ts
export default ({ config }) => ({
  ...config,
  extra: {
    brainUrl: process.env.VANCE_BRAIN_URL ?? 'http://localhost:8080',
  },
});

bootMobile.ts calls configurePlatform with this URL.

8.2 wb-Integration

New targets in wb:

wb build mobile          # pnpm install + pnpm --filter @vance/vance-fingers build (Type-Check)
wb mobile dev            # pnpm --filter @vance/vance-fingers start
wb mobile build:ios      # eas build --profile production --platform ios
wb mobile build:android  # eas build --profile production --platform android

wb build mobile does not run automatically in wb build all — Mobile requires Node and is optional, like Web today.

8.3 EAS Build / Stores

  • EAS Build: Cloud build via eas.json. Free tier is sufficient for low build frequency; paid tier for continuous release.
  • App Store / Play Store: Manual submission after EAS build. Privacy Manifest (iOS) and Data Safety (Android) are declared in eas.json / app.json: STT/TTS permissions, no tracking, no advertising.
  • OTA Updates via expo-updates: JS bundle updates without store resubmit. Useful for bug fixes, not for native code changes. v1: on, channel production.

8.4 Version Pinning

@vance/shared and @vance/generated are referenced via workspace:*. The Mobile build therefore always takes the local state. For store release, the pnpm lock hash is versioned (tag in Git).

9. App Lifecycle

9.1 Foreground / Background

Mobile apps are often pushed to the background or terminated entirely by the OS. Consequences:

Transition Behavior
App → Background WS connection of the Chat screen remains active as long as iOS/Android keeps the process alive. iOS typically gives 30s, Android varies.
App → Suspended (OS-killed) On resume, fresh WS connection. Chat state (streamingDrafts) is lost — REST history loads from server, dedupe as in §4.3.
Network Switch (WiFi → LTE) WS connection may break. BrainWebSocket must be able to reconnect with exponential backoff. On reconnect: deduplicate old message IDs from the last REST snapshot.
Complete Network Loss UI shows “Offline” (NetInfo listener). REST calls fail → TanStack-Query shows “stale”. Pull-to-Refresh retries. No offline cache in v1.

9.2 Reconnect Logic

Managed by @vance/shared/ws/brainWebSocket.ts — when BrainWebSocket.connect() is called, it returns a Promise; on close outside the regular logout path, the caller (useChatLive hook) is informed via onClose listener and decides whether to reconnect.

Exponential Backoff: 1s, 2s, 4s, 8s, max 30s. Reset on successful welcome.

Visibility: Connection state indicator (dot) in the Chat topbar — green (open), grey (reconnecting), red (gave up). Tap on the dot triggers manual reconnect.

10. What Mobile Does NOT Do (v1)

If a suggestion goes in one of these directions: stop and begin with a spec extension, do not implement ad-hoc.

  • Document Editing, neither text nor structured. Read-only, period.
  • Process Tree Display (Marvin / Vogon). Belongs on a large display.
  • Sessions/Settings/Recipes/Projects Editor. Web-only.
  • Tablet Layouts. iPhone and Android phone portrait. iPad gets a re-layout in v2.
  • Cross-Device Live Sync beyond the server state.
  • In-App Updates beyond what expo-updates does on its own.
  • Telemetry / Analytics. No third-party SDKs. No crash reporter in v1 (Sentry or similar later, with privacy review).

11. Push Notifications (v2 Preparation)

Push is the most obvious mobile added value: a critical Inbox item arrives, the user gets a notification, taps it, lands directly in the Inbox detail. v1 does not implement this, but prepares for it:

Preparation v1
expo-notifications as dependency, OS permission prompt on first Inbox tab entry
Send Expo Push Token to Brain after login (new REST endpoint POST /brain/{tenant}/devices) ❌ Endpoint does not exist yet — v2
Server-Side: NotificationDispatcher implements APNs/FCM sending ❌ Spec user-interaction.md §”Notification-Dispatcher” describes this as a stub — v2
Deep-Link vance://inbox/{id} opens Inbox detail ✅ (Linking config created, no user trigger v1)

This will be activated in v2. The slot remains open so that the refactor remains small.

12. Risks and Open Points

12.1 WS Stability on Mobile

Main risk. Real tests in tunnels / on trains / with Wi-Fi changes must happen before the mobile chat experience is marketed. Possibly fallback to REST polling for non-streaming-critical chat updates — this would be a spec change in v1.x.

12.2 Bearer Refresh Race

If two parallel REST calls both receive 401, two refresh requests would run in parallel. @vance/shared/auth/refreshClient.ts needs a single-flight pattern: a running refresh promise is cached, parallel callers wait for it. Web did not need this as urgently because cookies are serialized by the browser — Mobile explicitly needs it.

12.3 STT Language vs. Account Language

prefsStore.vance.speechLanguage is the STT/TTS BCP-47 code. If the account has a different default language (webui.language), this can drift apart. v1: speechLanguage takes precedence, no auto-sync. This is accepted because many users want to speak in language A and chat in language B.

12.4 React-Native-Voice Maintainership

@react-native-voice/voice is community-maintained and has had phases with outdated RN versions. If this breaks in practice, an alternative is expo-speech-recognition (in Expo SDK from v52) — same API structure, different maintainer. Dependency choice will be finally decided during implementation.

13. Reference

  • specification/web-ui.md — Sister spec, common concepts (Token Lifecycle §5, Style Guide §7)
  • specification/user-interaction.md — Inbox subsystem (Item Types, Lifecycle, Notification Dispatcher Stub)
  • specification/arthur-engine.md — Chat Engine (Streaming, Inbox/ProcessEvent)
  • specification/websocket-protokoll.md — WS Frame contract, Token Auth via Query Param
  • specification/recipes.md §6a — Profile block selection on server side (mobile profile)