mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 08:02:31 +00:00
* feat(mobile): the page can claim the device Back key The shell's page had no way to hear Android Back: every sheet inside it early-returned on web, so the key popped the whole session route. Adds the first negotiated shell-to-page frame kind alongside it. - `back-claim`, page to shell, declared in `init.accepts`: the document is holding the key, or has let it go. - `back`, shell to page, declared in `ready.accepts`: one press, dispatched to the newest consumer that takes it. A press nothing takes is handed back as a `navigate-back` rather than dropped. Both are optional fields on frames the other side already reads, so an old shell never hears a claim and an old page is never sent a press; each pops as it does today. No protocol bump and no stream opcode. `bridge-host.ts` was at its line cap, so the notify forwarder moves to `bridge-host-notify.ts` unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): Android Back closes the sheet on the page, not the screen Inside the shell's page every sheet early-returned on web, so one press left the session route with the sheet still open. The drawer, the right drawer and the file-preview prompt now claim the key through one seam on both platforms: `use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on the shell's. All sixteen session sheets render through `MountedBottomDrawer`, so the one claim there covers every one of them, and a census fails if a sheet bypasses it. `route-handoff.web.ts` claims while the page grew a stack of its own, and hands the press back when it did not. The shell takes the key off the navigator only while a claim is live: Android gets a `hardwareBackPress` handler that returns the host's own answer, iOS loses the stack's swipe-back. The claim is cleared on `document-started`, on a remount, on a new `ready`, on anything that takes the generation off screen, on the page's `close` and on dispose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): a Back press closes a sheet on the real bundle The unit suites reach both halves of the lane but never the two together on a document a browser rendered. The render rig can now post a `back` frame, and the drawer check opens the Filter sheet, reads the claim off the notify list, sends one press and pins that the sheet closed with no `navigate-back` behind it. Red without the drawer's claim: the claim never arrives. Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is shared with the native app and mounts under no page provider in a bare tree, where `usePageBridgeClient` threw; the seam now reads the bridge through `usePageBridgeClientIfPresent` and claims nothing without one. Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route handoff, `bridge-page-back.ts` through the envelope. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): mirror the Back seam's latest values from an effect Three `ref.current = …` writes sat in render, which React replays and discards. Each moves into a dependency-list-free effect declared ahead of the registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already uses for the same reason: the caller rebuilds the value every render, so there is nothing to depend on, and `useRef` seeds the first mount. The registration still keys on the claim alone, so a rebuilt handler re-registers nothing. The web seam's test drops its two type assertions for a named fixture type. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page says its Back claim again on every init The claim was edge-triggered and the shell forgets on purpose: it drops the claim answering every `ready`, and a host rebuilt under a live page — a client swap through forceReconnect, which leaves the WebView mounted — starts with none at all. A document still holding a sheet was then unknown to the shell, and the next press popped the screen out from under it. `init` is the shell saying it is here now, so the page answers each one with the state rather than with a transition. Posted after the session has taken the frame, so the gate reads that `init`'s own `accepts` and a shell that never named the claim still hears nothing. Nothing is said while nothing is held. Every `init` answering a `ready` comes from a host that dropped the claim first, so it already holds false; the only other one carries a rewritten route, where a stale true needs a `false` the page posted to have never left, and a port that refused that frame refuses this one too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a rebuilt host keeps the session's Back claim A host is rebuilt when the client under it changes, and the page document does not move: the WebView stays mounted, the session id holds, and the page is never told. The rebuilt host started with no claim and no `accepts`, so it refused every press and the navigator popped the screen out from under an open sheet. Two clients on the same generation leave the page nothing to refuse, so nothing made it re-ask and re-assert. What the page declared and what it is holding are facts about the session, the way `sessionEstablished` already is. `createBridgeHostBack` takes them as a seed, `readSessionBack()` hands them on, and the hook holds them stamped with the session so a record left by one never seeds the next. `dispose()` no longer reports the claim gone: a host retiring is not a document ending, and that report was the thing taking the key off a live sheet. Every reset path is unchanged and still has its own case — the page's `ready`, its `close`, and the session's own store for `document-started`, `remounted` and anything that takes the generation off screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
480 lines
19 KiB
TypeScript
480 lines
19 KiB
TypeScript
import { useEffect, useState, type ReactNode } from 'react'
|
|
import {
|
|
ActivityIndicator,
|
|
Linking,
|
|
Platform,
|
|
Pressable,
|
|
StyleSheet,
|
|
Text,
|
|
View
|
|
} from 'react-native'
|
|
import { useNavigation, useRouter } from 'expo-router'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import {
|
|
OrcaMobileWebShellView,
|
|
parseMobileWebShellLoadState
|
|
} from '../../modules/orca-mobile-web-shell/src'
|
|
import { HostRouteNoticeBanner } from '../components/HostRouteNoticeBanner'
|
|
import { ProtocolBlockScreen } from '../components/ProtocolBlockScreen'
|
|
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
|
import type { BridgeInitRoute } from './bridge/bridge-envelope'
|
|
import type { BridgeClearableRouteParam } from './bridge/bridge-route-update'
|
|
import type {
|
|
MobileWebShellFailureCause,
|
|
MobileWebShellSessionState,
|
|
MobileWebShellUpdateNotice
|
|
} from './mobile-web-shell-session-contract'
|
|
import {
|
|
formatMobileWebShellDevFacts,
|
|
isDevelopmentBuild,
|
|
useMobileWebShellDroppedFrames
|
|
} from './mobile-web-shell-dev-facts'
|
|
import { cancelledShellNavigationTarget } from './cancelled-navigation-target'
|
|
import { playPageHaptic } from './page-haptics'
|
|
import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge'
|
|
import type { MobileWebShellRuntime } from './mobile-web-shell-runtime'
|
|
import { useKeyboardOcclusion } from '../platform/keyboard-occlusion'
|
|
import { softwareKeyboardWindowInset } from '../platform/software-keyboard-window-inset'
|
|
import { useNativeDeviceVerbs } from '../platform/use-native-device-verbs'
|
|
import { useShellPageBack } from './use-shell-page-back'
|
|
import { useShellStackPop } from './use-shell-stack-pop'
|
|
import { useMobileWebShellSession } from './use-mobile-web-shell-session'
|
|
import { usePageHostSnapshot } from './use-page-host-snapshot'
|
|
import { SHELL_OPENING_LABEL, ShellPageCover, ShellWaitingFrame } from './ShellWaitingFrame'
|
|
|
|
function failureMessage(reason: MobileWebShellFailureCause): string {
|
|
switch (reason) {
|
|
case 'isolation-unavailable':
|
|
return "This device's WebView is too old to open the workspace safely."
|
|
case 'download-failed':
|
|
return 'The workspace could not be downloaded from this host.'
|
|
case 'status-unreadable':
|
|
return "Could not read this host's status. Go back and reopen it."
|
|
case 'render-process-gone':
|
|
return 'The workspace stopped responding.'
|
|
case 'generation-unreadable':
|
|
case 'document-load-failed':
|
|
return 'The downloaded workspace could not be opened.'
|
|
}
|
|
}
|
|
|
|
/** Says what happened and what is on screen because of it, and claims nothing else: the shell does
|
|
* not schedule a second attempt, so this must not promise one. */
|
|
function updateNoticeMessage(notice: MobileWebShellUpdateNotice): string {
|
|
switch (notice) {
|
|
case 'update-failed':
|
|
return "Couldn't update the workspace from this host. Showing the last version that worked."
|
|
}
|
|
}
|
|
|
|
function Centered({ children }: { children: ReactNode }) {
|
|
return <View style={styles.centered}>{children}</View>
|
|
}
|
|
|
|
function Waiting({ label }: { label: string }) {
|
|
return (
|
|
<Centered>
|
|
<ShellWaitingFrame label={label} />
|
|
</Centered>
|
|
)
|
|
}
|
|
|
|
function Fetching({ state }: { state: Extract<MobileWebShellSessionState, { kind: 'fetching' }> }) {
|
|
return (
|
|
<Centered>
|
|
<ActivityIndicator color={colors.textSecondary} accessibilityLabel="Downloading workspace" />
|
|
<Text style={styles.waitingLabel}>Downloading workspace</Text>
|
|
<Text style={styles.progress} testID="mobile-web-shell-progress">
|
|
{`${state.completedAssets}/${state.totalAssets} files · ${state.receivedBytes}/${state.totalBytes} bytes`}
|
|
</Text>
|
|
</Centered>
|
|
)
|
|
}
|
|
|
|
function Failed({
|
|
state,
|
|
onRetry
|
|
}: {
|
|
state: Extract<MobileWebShellSessionState, { kind: 'failed' }>
|
|
onRetry: () => void
|
|
}) {
|
|
// No retry for the fence, and none for an unread status: a device whose WebView cannot be
|
|
// isolated will not grow one on a tap, and a retry re-reads the same settled gate it already has.
|
|
const retryable = state.reason !== 'isolation-unavailable' && state.reason !== 'status-unreadable'
|
|
return (
|
|
<Centered>
|
|
<Text style={styles.failedMessage} testID="mobile-web-shell-failed">
|
|
{failureMessage(state.reason)}
|
|
</Text>
|
|
{retryable ? (
|
|
<Pressable
|
|
style={({ pressed }) => [styles.retryButton, pressed && styles.pressed]}
|
|
testID="mobile-web-shell-retry"
|
|
onPress={onRetry}
|
|
>
|
|
<Text style={styles.retryLabel}>Try again</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</Centered>
|
|
)
|
|
}
|
|
|
|
function DevFacts({
|
|
state,
|
|
droppedBinaryFrames
|
|
}: {
|
|
state: Extract<MobileWebShellSessionState, { kind: 'ready' }>
|
|
droppedBinaryFrames: number
|
|
}) {
|
|
if (!isDevelopmentBuild()) {
|
|
return null
|
|
}
|
|
return (
|
|
<View style={styles.devFacts} pointerEvents="none">
|
|
<Text style={styles.devFactsText} testID="mobile-web-shell-dev-facts">
|
|
{formatMobileWebShellDevFacts({
|
|
buildId: state.buildId,
|
|
totalBytes: state.totalBytes,
|
|
elapsedMs: state.elapsedMs,
|
|
droppedBinaryFrames
|
|
})}
|
|
</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export type MobileWebShellScreenProps = {
|
|
hostId: string
|
|
/** The screen this shell stands in for, which the page cannot derive from a document served at `/`. */
|
|
route: BridgeInitRoute
|
|
/**
|
|
* What to render when the bundle does not list this route, or lists it needing a grant this app
|
|
* does not implement. Required, because every caller has a native screen behind it: that is what
|
|
* the negotiation falls back to, and a shell with nothing behind it would paint a blank instead.
|
|
*/
|
|
fallback: ReactNode
|
|
/**
|
|
* The page applied a one-shot route param and asks for it to be erased (ruling 34), naming what
|
|
* it applied. Only a caller that put one on the route ever hears this, and the comparison is
|
|
* that caller's: it holds the param, and a tap that moved on since leaves a newer value there.
|
|
*/
|
|
onRouteParamClear?: (param: BridgeClearableRouteParam, value: string) => void
|
|
runtime?: MobileWebShellRuntime
|
|
}
|
|
|
|
/**
|
|
* The hybrid shell route's screen: one generation, rendered by the native view, or the plain state
|
|
* that says why it is not.
|
|
*
|
|
* The native view is keyed on the session id, so a remount the reducer asks for is a new key and a
|
|
* rebuilt WebView with every fence reinstalled — the view has no reload of its own by design.
|
|
*/
|
|
export function MobileWebShellScreen({
|
|
hostId,
|
|
route,
|
|
fallback,
|
|
onRouteParamClear,
|
|
runtime
|
|
}: MobileWebShellScreenProps) {
|
|
const insets = useSafeAreaInsets()
|
|
// The page cannot see the IME for itself: edge-to-edge makes the manifest's `adjustResize` inert,
|
|
// so the window never shrinks and `visualViewport` inside the WebView reads full height with the
|
|
// keyboard up — the session route lays its live input row out under the keys. The shell owns the
|
|
// window, so it takes the strip off the view and the page lays out in what is left.
|
|
const keyboardHeight = useKeyboardOcclusion()
|
|
const keyboardInset = softwareKeyboardWindowInset({
|
|
keyboardHeight,
|
|
bottomInset: insets.bottom,
|
|
platform: Platform.OS
|
|
})
|
|
const router = useRouter()
|
|
const navigation = useNavigation()
|
|
const popShellStack = useShellStackPop()
|
|
const { droppedBinaryFrames, reportDroppedBinaryFrames } = useMobileWebShellDroppedFrames()
|
|
const {
|
|
state,
|
|
pageRoutes,
|
|
pageRouteGrants,
|
|
routeGrants,
|
|
updateNotice,
|
|
retry,
|
|
reportShellFailure,
|
|
reportDocumentStarted,
|
|
reportDocumentLoaded,
|
|
reportPageReady,
|
|
reportPagePainted,
|
|
reportPageBackClaim,
|
|
pageReady,
|
|
pageFrame,
|
|
backClaimed
|
|
} = useMobileWebShellSession({ hostId, routePathname: route.pathname, runtime })
|
|
// Which mount the notice was dismissed on, not whether it was: a later refusal opens its own
|
|
// generation under a new session id, so it is not silenced by a tap on the one before it.
|
|
const [noticeDismissedFor, setNoticeDismissedFor] = useState<string | null>(null)
|
|
const { snapshot, unreadable, readStorage, refreshStorage, writeStorage } = usePageHostSnapshot(
|
|
hostId,
|
|
route.pathname
|
|
)
|
|
// Declared before the bridge so the handler it is handed already belongs to this session: the
|
|
// media verbs hold staged files, and a registry born after the host would outlive the page.
|
|
const serveNativeVerb = useNativeDeviceVerbs(state.kind === 'ready' ? state.sessionId : null)
|
|
// Straight to the system handler, and the one opener the shell has: the page's `externalLink`
|
|
// notify and a cancelled top-frame navigation both arrive here already filtered. The only failure
|
|
// left is a device with nothing registered for the scheme -- a `mailto:` on a phone with no mail
|
|
// account. Reported rather than swallowed, because nothing crosses back for either path, and not
|
|
// rethrown, because both run on a native frame handler.
|
|
const openUrlForPage = (url: string) => {
|
|
void Linking.openURL(url).catch((error: unknown) => {
|
|
console.warn('[web-shell] could not open a URL for the page', { url, error })
|
|
})
|
|
}
|
|
|
|
const bridge = useMobileWebShellBridge({
|
|
hostId,
|
|
route,
|
|
pageRoutes,
|
|
pageRouteGrants,
|
|
routeGrants,
|
|
session: state,
|
|
sessionEstablished: pageReady,
|
|
snapshot,
|
|
readStorage,
|
|
onStorageWrite: writeStorage,
|
|
// Reported as the document failing to load, which is what it is: the document loaded and never
|
|
// produced a tree. That reason drops this generation and downloads once, so a page broken by
|
|
// bytes this host has since replaced recovers, and a page broken by its own code stops at the
|
|
// failure screen instead of a blank one.
|
|
// Must not throw: it runs inside the page's own error boundary on one side and the native frame
|
|
// handler on the other, and neither has anywhere to put a throw.
|
|
onPageFault: (error) => {
|
|
console.warn('[web-shell] the page faulted', error)
|
|
reportShellFailure('document-load-failed')
|
|
},
|
|
// The `init` this ready is answered with is already built from the app's writes, which reach
|
|
// the map as they are made. This re-seats that map on the store afterwards, for the key whose
|
|
// write never persisted, and it runs on every ask because a document that reloads inside this
|
|
// mount asks again.
|
|
onPageReady: (reports) => {
|
|
reportPageReady(reports)
|
|
void refreshStorage()
|
|
},
|
|
// The one thing that says the page is something to look at. The cover below stays up until it
|
|
// lands, for a page that declared it would send one.
|
|
onPagePainted: reportPagePainted,
|
|
// While this is true the key below belongs to the page, not to the stack this screen sits on.
|
|
onPageBackClaim: reportPageBackClaim,
|
|
onRouteParamClear: (param, value) => {
|
|
onRouteParamClear?.(param, value)
|
|
},
|
|
// `document-load-failed` because that is what happens: the document loads and the page refuses
|
|
// the session, so no tree is ever built. The refetch it costs is wasted on a route this shell
|
|
// produced, and the second report is terminal, which is the failure screen this deserves.
|
|
onRouteRefused: (issue) => {
|
|
console.warn('[web-shell] refused to open this screen', issue)
|
|
reportShellFailure('document-load-failed')
|
|
},
|
|
// Pushed, never replaced: the page stays mounted underneath, so Back reveals it with no
|
|
// download and no second `init`.
|
|
onNavigate: (href: string) => {
|
|
router.push(href)
|
|
},
|
|
// Answered on this device and never forwarded; the host holds it to the verb table first.
|
|
serveNativeVerb,
|
|
// Straight to the system handler. The envelope allowlisted the scheme before this ran, so the
|
|
// only failure left is a device with nothing registered for it — a `mailto:` on a phone with no
|
|
// mail account. Reported rather than swallowed: nothing crosses back for a notify, so this is
|
|
// the one dead tap the verb does not rule out, and silence is what would hide it. Still not
|
|
// rethrown, because this runs on the native frame handler.
|
|
// The same opener a cancelled top-frame navigation takes, hoisted above this call so both
|
|
// paths are one function: its body is the `Linking.openURL` and the warning this handler
|
|
// carried inline.
|
|
onExternalLink: openUrlForPage,
|
|
// The app's own haptics, reached through one mapping rather than a second copy of the
|
|
// `Platform.OS` split. Nothing crosses back and nothing can fail: each function already
|
|
// swallows its own rejection on the device.
|
|
onHaptic: playPageHaptic,
|
|
// The page's own Back goes nowhere: it holds the one history entry the entry wrote, so the only
|
|
// stack to pop is this one.
|
|
onNavigateBack: popShellStack,
|
|
// A dropped screencast frame leaves no other trace on a device: the stream stays up by design
|
|
// and the diagnostic beside it prints once per host.
|
|
onBinaryFramesDropped: reportDroppedBinaryFrames
|
|
})
|
|
|
|
// A route that moved under a screen that stayed mounted: the session switch keeps `paneKey` out
|
|
// of its key so a notification tap for another pane is a tab switch rather than a page reload,
|
|
// and this is how the page hears about it. Nothing is tracked here — which route the page has,
|
|
// and which one is still owed it, belong to the host, which outlives any one run of this effect.
|
|
// All this says is what the screen is on now: a route that did not move is dropped there, and a
|
|
// frame in flight when this re-runs is not disturbed by it.
|
|
const publishRoute = bridge.publishRoute
|
|
useEffect(() => {
|
|
publishRoute(route)
|
|
}, [publishRoute, route])
|
|
|
|
// The navigation object rather than the router: what this takes away is this screen's own place
|
|
// on the stack, which is a screen option, and the router has no member that says it.
|
|
useShellPageBack({
|
|
claimed: backClaimed,
|
|
sendBack: bridge.sendBack,
|
|
setOptions: navigation.setOptions
|
|
})
|
|
|
|
// A profile read that rejected never becomes a host, so the session would otherwise sit in
|
|
// `ready` behind an un-hidden view with nothing serving it and the page asking forever.
|
|
// `document-load-failed` because that is the outcome: the document loads and no session opens.
|
|
// The refetch it costs is wasted on a device-local read, and the second report is terminal, which
|
|
// is the failure screen with a Try again this deserves.
|
|
useEffect(() => {
|
|
if (unreadable) {
|
|
console.warn('[web-shell] this host could not be read from the app store')
|
|
reportShellFailure('document-load-failed')
|
|
}
|
|
}, [reportShellFailure, unreadable])
|
|
|
|
if (state.kind === 'native-route') {
|
|
return fallback
|
|
}
|
|
if (state.kind === 'wall') {
|
|
return <ProtocolBlockScreen verdict={state.verdict} />
|
|
}
|
|
if (state.kind === 'failed') {
|
|
return <Failed state={state} onRetry={retry} />
|
|
}
|
|
if (state.kind === 'offline') {
|
|
return (
|
|
<Centered>
|
|
<Text style={styles.waitingLabel} testID="mobile-web-shell-offline">
|
|
Connect to this host to download the workspace
|
|
</Text>
|
|
</Centered>
|
|
)
|
|
}
|
|
if (state.kind === 'fetching') {
|
|
return <Fetching state={state} />
|
|
}
|
|
if (state.kind !== 'ready') {
|
|
return <Waiting label={state.kind === 'activating' ? SHELL_OPENING_LABEL : 'Checking host'} />
|
|
}
|
|
return (
|
|
<View
|
|
style={[
|
|
styles.shellRoot,
|
|
{ paddingTop: insets.top, paddingBottom: Math.max(insets.bottom, keyboardInset) }
|
|
]}
|
|
testID="mobile-web-shell-ready"
|
|
>
|
|
{/* Above the page and dismissible, never in front of it: the workspace below this line
|
|
works, and the only thing that did not happen is the update to a newer one. */}
|
|
{updateNotice !== null && noticeDismissedFor !== state.sessionId && (
|
|
<HostRouteNoticeBanner
|
|
message={updateNoticeMessage(updateNotice)}
|
|
tone="failure"
|
|
onDismiss={() => setNoticeDismissedFor(state.sessionId)}
|
|
/>
|
|
)}
|
|
<OrcaMobileWebShellView
|
|
key={state.sessionId}
|
|
ref={bridge.viewRef}
|
|
style={styles.shellView}
|
|
generationDirectory={state.generationDirectory}
|
|
sessionId={state.sessionId}
|
|
bridgeEnabled={bridge.bridgeEnabled}
|
|
onBridgeMessage={bridge.onBridgeMessage}
|
|
onExternalNavigation={(event) => {
|
|
const target = cancelledShellNavigationTarget(event.nativeEvent.url)
|
|
if (target === null) {
|
|
// Cancelled and not openable. Nothing naming the shell's own document reaches here:
|
|
// the shell refuses that without offering it, whatever asked. What lands here and is
|
|
// dropped is a URL outside the three allowed schemes. Silent, as every cancelled
|
|
// navigation was before this event existed.
|
|
return
|
|
}
|
|
openUrlForPage(target)
|
|
}}
|
|
onLoadState={(event) => {
|
|
const parsed = parseMobileWebShellLoadState(event.nativeEvent)
|
|
if (parsed?.state === 'failed') {
|
|
reportShellFailure(parsed.reason)
|
|
return
|
|
}
|
|
// A finished document is not a working one. The WebView says the response committed; only
|
|
// the page's own first frame says its code ran, so this is where the wait for it starts.
|
|
if (parsed?.state === 'ready') {
|
|
reportDocumentLoaded()
|
|
return
|
|
}
|
|
// The view is drawing the document it is leaving until the new one paints, so the cover
|
|
// goes back up here rather than on the `ready` that follows it.
|
|
if (parsed?.state === 'loading') {
|
|
reportDocumentStarted()
|
|
}
|
|
}}
|
|
/>
|
|
<ShellPageCover label={SHELL_OPENING_LABEL} visible={pageFrame === 'unpainted'} />
|
|
<DevFacts state={state} droppedBinaryFrames={droppedBinaryFrames} />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
shellRoot: {
|
|
flex: 1,
|
|
backgroundColor: colors.bgBase
|
|
},
|
|
shellView: {
|
|
flex: 1
|
|
},
|
|
centered: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: colors.bgBase,
|
|
paddingHorizontal: spacing.lg
|
|
},
|
|
waitingLabel: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textSecondary,
|
|
marginTop: spacing.md,
|
|
textAlign: 'center'
|
|
},
|
|
progress: {
|
|
fontSize: typography.metaSize,
|
|
color: colors.textMuted,
|
|
marginTop: spacing.sm
|
|
},
|
|
failedMessage: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textPrimary,
|
|
textAlign: 'center',
|
|
marginBottom: spacing.lg
|
|
},
|
|
retryButton: {
|
|
backgroundColor: colors.bgRaised,
|
|
paddingVertical: spacing.sm + 2,
|
|
paddingHorizontal: spacing.lg,
|
|
borderRadius: radii.button
|
|
},
|
|
retryLabel: {
|
|
fontSize: typography.bodySize,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary
|
|
},
|
|
pressed: {
|
|
opacity: 0.7
|
|
},
|
|
devFacts: {
|
|
position: 'absolute',
|
|
left: spacing.sm,
|
|
bottom: spacing.sm,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: 2,
|
|
borderRadius: radii.button,
|
|
backgroundColor: colors.bgPanel
|
|
},
|
|
devFactsText: {
|
|
fontSize: typography.metaSize,
|
|
color: colors.textMuted
|
|
}
|
|
})
|