@oxyhq/services 13.1.7 → 13.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { useCallback, useRef, useState } from 'react';
3
3
  import {
4
4
  View,
5
5
  Pressable,
6
+ StyleSheet,
6
7
  Platform,
7
8
  type StyleProp,
8
9
  type ViewStyle,
@@ -73,7 +74,23 @@ export interface ProfileButtonProps {
73
74
  onNavigateProfile?: () => void;
74
75
  /** Called before the active identity changes so apps can clear scoped state. */
75
76
  onBeforeSessionChange?: () => void | Promise<void>;
76
- /** Extra className applied to the outer trigger. */
77
+ /**
78
+ * Web-only popover direction relative to the trigger:
79
+ * - `'up'` (default): the menu opens UPWARD, for a trigger placed at the
80
+ * BOTTOM of a sidebar (the footer pattern). Pixel-identical to prior
81
+ * behavior.
82
+ * - `'down'`: the menu opens DOWNWARD, for a trigger placed at the TOP of a
83
+ * sidebar (the accounts app pattern).
84
+ * - `'auto'`: opens downward when there is more room below the trigger than
85
+ * above, otherwise upward.
86
+ * Native (bottom-sheet) is unaffected — this only influences the web popover.
87
+ */
88
+ placement?: 'up' | 'down' | 'auto';
89
+ /**
90
+ * Extra className applied to the outer trigger. Kept for NativeWind consumers
91
+ * that layer utility classes on top; the component's own layout is driven by
92
+ * `StyleSheet` so it renders correctly with or without NativeWind.
93
+ */
77
94
  className?: string;
78
95
  /** Extra style applied to the outer trigger. */
79
96
  style?: StyleProp<ViewStyle>;
@@ -92,6 +109,12 @@ export interface ProfileButtonProps {
92
109
  * against the measured trigger, or as a bottom sheet (native).
93
110
  * - **Signed out**: a "Sign in" row that calls `useAuth().signIn()`.
94
111
  *
112
+ * Styling uses react-native `StyleSheet` + the Bloom theme (via `useTheme`) so
113
+ * the layout renders identically in EVERY consumer — including apps that do not
114
+ * use NativeWind (e.g. the accounts app). Only the web hover animation keeps
115
+ * dynamic inline `style` (the CSS transition/transform values), which is what
116
+ * the `react-native-web-style.d.ts` augmentation exists for.
117
+ *
95
118
  * All display strings resolve through `@oxyhq/core`'s
96
119
  * `getAccountDisplayName` / `getAccountFallbackHandle` — no hand-rolled name
97
120
  * fallbacks. Avatars pass the bare file id as `source` + `variant="thumb"`, so
@@ -104,6 +127,7 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
104
127
  onAddAccount,
105
128
  onNavigateProfile,
106
129
  onBeforeSessionChange,
130
+ placement = 'up',
107
131
  className,
108
132
  style,
109
133
  }) => {
@@ -143,7 +167,7 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
143
167
  setMenuOpen(true);
144
168
  return;
145
169
  }
146
- node.measure((_x, _y, _w, _h, pageX, pageY) => {
170
+ node.measure((_x, _y, _w, height, pageX, pageY) => {
147
171
  if (typeof pageX !== 'number' || typeof pageY !== 'number') {
148
172
  setAnchor(null);
149
173
  } else {
@@ -153,17 +177,33 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
153
177
  window.innerWidth - MENU_WIDTH - VIEWPORT_GUTTER,
154
178
  );
155
179
  const left = Math.min(Math.max(VIEWPORT_GUTTER, pageX), maxLeft);
156
- // Anchor the panel's BOTTOM edge just above the trigger top so
157
- // the menu opens upward from this footer button.
158
- const bottom = Math.max(
159
- VIEWPORT_GUTTER,
160
- window.innerHeight - pageY + VIEWPORT_GUTTER,
161
- );
162
- setAnchor({ left, bottom });
180
+ // Bottom edge of the trigger in page coordinates. `height` is a
181
+ // number for host views; guard defensively for non-web shims.
182
+ const triggerBottom = pageY + (typeof height === 'number' ? height : 0);
183
+ // Resolve `'auto'` by comparing the room below the trigger with
184
+ // the room above it; pick whichever direction has more space.
185
+ const openDown =
186
+ placement === 'down' ||
187
+ (placement === 'auto' &&
188
+ window.innerHeight - triggerBottom > pageY);
189
+ if (openDown) {
190
+ // Anchor the panel's TOP edge just below the trigger bottom so
191
+ // the menu opens downward from a top-of-sidebar trigger.
192
+ const top = Math.max(VIEWPORT_GUTTER, triggerBottom + VIEWPORT_GUTTER);
193
+ setAnchor({ left, top });
194
+ } else {
195
+ // Anchor the panel's BOTTOM edge just above the trigger top so
196
+ // the menu opens upward from this footer button.
197
+ const bottom = Math.max(
198
+ VIEWPORT_GUTTER,
199
+ window.innerHeight - pageY + VIEWPORT_GUTTER,
200
+ );
201
+ setAnchor({ left, bottom });
202
+ }
163
203
  }
164
204
  setMenuOpen(true);
165
205
  });
166
- }, []);
206
+ }, [placement]);
167
207
 
168
208
  const handleClose = useCallback(() => {
169
209
  setMenuOpen(false);
@@ -179,8 +219,14 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
179
219
  importantForAccessibility="no-hide-descendants"
180
220
  >
181
221
  <View
182
- className="rounded-full bg-secondary"
183
- style={{ width: resolvedAvatarSize, height: resolvedAvatarSize }}
222
+ style={[
223
+ styles.skeletonCircle,
224
+ {
225
+ width: resolvedAvatarSize,
226
+ height: resolvedAvatarSize,
227
+ backgroundColor: colors.backgroundSecondary,
228
+ },
229
+ ]}
184
230
  />
185
231
  </View>
186
232
  );
@@ -191,15 +237,21 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
191
237
  const signInLabel = t('common.actions.signIn') || 'Sign in';
192
238
  return (
193
239
  <Pressable
194
- className={`${expanded ? 'w-full ' : ''}flex-row items-center gap-3 rounded-full px-2 py-2 ${className ?? ''}`}
195
- style={style}
240
+ className={className}
241
+ style={[styles.row, expanded && styles.rowExpanded, style]}
196
242
  onPress={() => { void signIn(); }}
197
243
  accessibilityRole="button"
198
244
  accessibilityLabel={signInLabel}
199
245
  >
200
246
  <View
201
- className="items-center justify-center rounded-full bg-secondary"
202
- style={{ width: resolvedAvatarSize, height: resolvedAvatarSize }}
247
+ style={[
248
+ styles.avatarBadge,
249
+ {
250
+ width: resolvedAvatarSize,
251
+ height: resolvedAvatarSize,
252
+ backgroundColor: colors.backgroundSecondary,
253
+ },
254
+ ]}
203
255
  >
204
256
  <MaterialCommunityIcons
205
257
  name="login"
@@ -209,7 +261,7 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
209
261
  </View>
210
262
  {expanded ? (
211
263
  <Text
212
- className="flex-1 font-semibold text-foreground"
264
+ style={[styles.signInLabel, { color: colors.text }]}
213
265
  numberOfLines={1}
214
266
  >
215
267
  {signInLabel}
@@ -267,8 +319,7 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
267
319
  <View className={className} style={style}>
268
320
  <Pressable
269
321
  ref={triggerRef}
270
- className="rounded-full"
271
- style={collapsedBgStyle}
322
+ style={[styles.collapsedTrigger, collapsedBgStyle]}
272
323
  onPress={openMenu}
273
324
  accessibilityRole="button"
274
325
  accessibilityLabel={accountLabel}
@@ -300,7 +351,7 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
300
351
  // Slide the shrunk avatar left so its visual left edge stays put as it
301
352
  // contracts. The avatar loses `size * (1 - scale)` of width; half of that
302
353
  // (the left half, since scale is centered) is the offset, plus the row's
303
- // left padding (px-2 = 8px) so it hugs the row edge like Bluesky's.
354
+ // left padding (8px) so it hugs the row edge like Bluesky's.
304
355
  const activeAvatarTranslateX =
305
356
  -(resolvedAvatarSize * (1 - ACTIVE_AVATAR_SCALE)) / 2 - 8;
306
357
 
@@ -349,23 +400,28 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
349
400
  : undefined;
350
401
 
351
402
  return (
352
- <View className={`w-full ${className ?? ''}`} style={style}>
403
+ <View className={className} style={[styles.fullWidth, style]}>
353
404
  <Pressable
354
405
  ref={triggerRef}
355
- className="w-full flex-row items-center gap-3 rounded-full px-2 py-2"
356
- style={rowStyle}
406
+ style={[styles.row, styles.rowExpanded, rowStyle]}
357
407
  onPress={openMenu}
358
408
  accessibilityRole="button"
359
409
  accessibilityLabel={accountLabel}
360
410
  {...webInteractionProps}
361
411
  >
362
412
  <View style={avatarWrapperStyle}>{avatarNode}</View>
363
- <View className="min-w-0 flex-1" style={identityStyle}>
364
- <Text className="font-bold text-foreground" numberOfLines={1}>
413
+ <View style={[styles.identity, identityStyle]}>
414
+ <Text
415
+ style={[styles.displayName, { color: colors.text }]}
416
+ numberOfLines={1}
417
+ >
365
418
  {displayName}
366
419
  </Text>
367
420
  {handleLine ? (
368
- <Text className="text-xs text-muted-foreground" numberOfLines={1}>
421
+ <Text
422
+ style={[styles.handle, { color: colors.textSecondary }]}
423
+ numberOfLines={1}
424
+ >
369
425
  {handleLine}
370
426
  </Text>
371
427
  ) : null}
@@ -391,4 +447,55 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
391
447
  );
392
448
  };
393
449
 
450
+ const styles = StyleSheet.create({
451
+ fullWidth: {
452
+ width: '100%',
453
+ },
454
+ // Neutral skeleton / signed-out avatar circle (`rounded-full`).
455
+ skeletonCircle: {
456
+ borderRadius: 9999,
457
+ },
458
+ // Shared trigger row (`flex-row items-center gap-3 rounded-full px-2 py-2`).
459
+ row: {
460
+ flexDirection: 'row',
461
+ alignItems: 'center',
462
+ gap: 12,
463
+ borderRadius: 9999,
464
+ paddingHorizontal: 8,
465
+ paddingVertical: 8,
466
+ },
467
+ // `w-full` on the expanded row + its signed-out variant.
468
+ rowExpanded: {
469
+ width: '100%',
470
+ },
471
+ // Signed-out login badge (`items-center justify-center rounded-full`).
472
+ avatarBadge: {
473
+ alignItems: 'center',
474
+ justifyContent: 'center',
475
+ borderRadius: 9999,
476
+ },
477
+ // Signed-out label (`flex-1 font-semibold`).
478
+ signInLabel: {
479
+ flex: 1,
480
+ fontWeight: '600',
481
+ },
482
+ // Collapsed avatar-only trigger (`rounded-full`).
483
+ collapsedTrigger: {
484
+ borderRadius: 9999,
485
+ },
486
+ // Identity block (`min-w-0 flex-1`).
487
+ identity: {
488
+ flex: 1,
489
+ minWidth: 0,
490
+ },
491
+ // Display name (`font-bold`).
492
+ displayName: {
493
+ fontWeight: '700',
494
+ },
495
+ // Handle line (`text-xs`).
496
+ handle: {
497
+ fontSize: 12,
498
+ },
499
+ });
500
+
394
501
  export default ProfileButton;
@@ -6,6 +6,7 @@ import {
6
6
  Modal,
7
7
  ScrollView,
8
8
  ActivityIndicator,
9
+ StyleSheet,
9
10
  Platform,
10
11
  type ViewStyle,
11
12
  } from 'react-native';
@@ -26,15 +27,20 @@ const isWeb = Platform.OS === 'web';
26
27
  const MENU_WIDTH = 300;
27
28
 
28
29
  /**
29
- * Web-only anchor. `ProfileButton` measures its trigger and anchors the panel's
30
- * BOTTOM-LEFT corner so the menu opens UPWARD from the sidebar footer. Native
31
- * ignores the anchor and docks the panel to the bottom as a sheet.
30
+ * Web-only anchor. `ProfileButton` measures its trigger and anchors the panel to
31
+ * one corner so the menu opens either UPWARD (footer trigger) or DOWNWARD (a
32
+ * trigger at the top of a sidebar). Exactly ONE vertical edge is pinned:
33
+ * - `bottom` set → the panel's BOTTOM edge is anchored, so it opens UPWARD.
34
+ * - `top` set → the panel's TOP edge is anchored, so it opens DOWNWARD.
35
+ * Native ignores the anchor and docks the panel to the bottom as a sheet.
32
36
  */
33
37
  export interface ProfileMenuAnchor {
34
38
  /** Distance from the viewport left, for the panel's LEFT edge. */
35
39
  left: number;
36
40
  /** Distance from the viewport bottom, for the panel's BOTTOM edge (opens upward). */
37
- bottom: number;
41
+ bottom?: number;
42
+ /** Distance from the viewport top, for the panel's TOP edge (opens downward). */
43
+ top?: number;
38
44
  }
39
45
 
40
46
  export interface ProfileMenuProps {
@@ -56,8 +62,9 @@ export interface ProfileMenuProps {
56
62
  type ProfileMenuContentProps = Omit<ProfileMenuProps, 'open'>;
57
63
 
58
64
  /**
59
- * Clean device-account switcher, modeled on the inbox `AccountMenu` but written
60
- * fresh with NativeWind classNames + Bloom primitives. Lists every account
65
+ * Clean device-account switcher, modeled on the inbox `AccountMenu` and styled
66
+ * with react-native `StyleSheet` + the Bloom theme (via `useTheme`) so it
67
+ * renders in EVERY consumer regardless of NativeWind. Lists every account
61
68
  * signed in on this device (from {@link useDeviceAccounts}); tapping a row
62
69
  * switches, and each inactive row carries a sign-out icon. Below the list:
63
70
  * Add account, Manage account, optional View profile, and Sign out of all.
@@ -170,8 +177,8 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
170
177
  return (
171
178
  <>
172
179
  <ScrollView
173
- className="grow-0"
174
- contentContainerClassName="py-1"
180
+ style={styles.scroll}
181
+ contentContainerStyle={styles.scrollContent}
175
182
  showsVerticalScrollIndicator={false}
176
183
  >
177
184
  {/* 1) Device accounts — active first (checkmark), others switchable. */}
@@ -187,9 +194,11 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
187
194
  accessibilityState={{ selected: isActive }}
188
195
  onPress={() => handleSwitch(account.sessionId)}
189
196
  disabled={isActive || isBusy || isSwitching}
190
- className={`flex-row items-center gap-3 px-4 py-2.5 ${
191
- isActive ? 'bg-secondary' : ''
192
- } ${isSwitching && !isActive ? 'opacity-40' : ''}`}
197
+ style={[
198
+ styles.accountRow,
199
+ isActive && { backgroundColor: colors.backgroundSecondary },
200
+ isSwitching && !isActive && styles.rowDimmed,
201
+ ]}
193
202
  >
194
203
  <Avatar
195
204
  source={account.user.avatar ?? undefined}
@@ -198,16 +207,19 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
198
207
  name={account.displayName}
199
208
  size={isActive ? 40 : 32}
200
209
  />
201
- <View className="min-w-0 flex-1">
210
+ <View style={styles.accountInfo}>
202
211
  <Text
203
- className={`text-foreground ${isActive ? 'font-semibold' : 'font-medium'}`}
212
+ style={[
213
+ isActive ? styles.accountNameActive : styles.accountName,
214
+ { color: colors.text },
215
+ ]}
204
216
  numberOfLines={1}
205
217
  >
206
218
  {account.displayName}
207
219
  </Text>
208
220
  {account.email ? (
209
221
  <Text
210
- className="text-xs text-muted-foreground"
222
+ style={[styles.accountEmail, { color: colors.textSecondary }]}
211
223
  numberOfLines={1}
212
224
  >
213
225
  {account.email}
@@ -230,7 +242,7 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
230
242
  onPress={() => handleRemove(account.sessionId)}
231
243
  disabled={actionDisabled}
232
244
  hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
233
- className="h-7 w-7 items-center justify-center rounded-full opacity-60"
245
+ style={styles.rowSignOutButton}
234
246
  >
235
247
  <MaterialCommunityIcons
236
248
  name="logout"
@@ -245,9 +257,9 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
245
257
 
246
258
  {/* 2) Switching indicator. */}
247
259
  {isSwitching ? (
248
- <View className="flex-row items-center justify-center gap-2 py-2">
260
+ <View style={styles.switchingRow}>
249
261
  <ActivityIndicator color={colors.textSecondary} size="small" />
250
- <Text className="text-xs font-medium text-muted-foreground">
262
+ <Text style={[styles.switchingText, { color: colors.textSecondary }]}>
251
263
  {t('accountMenu.switching') || 'Switching account…'}
252
264
  </Text>
253
265
  </View>
@@ -259,6 +271,7 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
259
271
  <ActionRow
260
272
  icon="account-plus-outline"
261
273
  iconColor={colors.icon}
274
+ textColor={colors.text}
262
275
  label={t('accountMenu.addAnother') || 'Add another account'}
263
276
  disabled={actionDisabled}
264
277
  onPress={() => {
@@ -271,6 +284,7 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
271
284
  <ActionRow
272
285
  icon="cog-outline"
273
286
  iconColor={colors.icon}
287
+ textColor={colors.text}
274
288
  label={t('accountMenu.manage') || 'Manage your Oxy Account'}
275
289
  disabled={actionDisabled}
276
290
  onPress={() => {
@@ -284,6 +298,7 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
284
298
  <ActionRow
285
299
  icon="account-outline"
286
300
  iconColor={colors.icon}
301
+ textColor={colors.text}
287
302
  label={t('accountMenu.viewProfile') || 'View profile'}
288
303
  disabled={actionDisabled}
289
304
  onPress={() => {
@@ -300,14 +315,14 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
300
315
  accessibilityLabel={t('accountMenu.signOutAll') || 'Sign out of all accounts'}
301
316
  onPress={() => signOutAllDialog.open()}
302
317
  disabled={actionDisabled}
303
- className={`flex-row items-center gap-3 px-4 py-3 ${actionDisabled ? 'opacity-40' : ''}`}
318
+ style={[styles.signOutAllRow, actionDisabled && styles.rowDimmed]}
304
319
  >
305
320
  {signingOutAll ? (
306
321
  <ActivityIndicator color={colors.error} size="small" />
307
322
  ) : (
308
323
  <MaterialCommunityIcons name="logout" size={18} color={colors.error} />
309
324
  )}
310
- <Text className="font-medium" style={{ color: colors.error }}>
325
+ <Text style={[styles.actionText, { color: colors.error }]}>
311
326
  {t('accountMenu.signOutAll') || 'Sign out of all accounts'}
312
327
  </Text>
313
328
  </Pressable>
@@ -352,16 +367,21 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({
352
367
  onBeforeSessionChange,
353
368
  }) => {
354
369
  const { t } = useI18n();
370
+ const { colors } = useTheme();
355
371
 
356
- // Web: anchor the panel's bottom-left corner to the measured trigger, falling
357
- // back to a bottom-left placement when no anchor was captured. Native ignores
358
- // this (the panel docks to the bottom via the overlay's flex-end).
372
+ // Web: anchor the panel to the measured trigger. When the anchor pins `top`
373
+ // the panel opens DOWNWARD from the trigger; otherwise it pins `bottom` and
374
+ // opens UPWARD (the default footer behavior). With no anchor captured, fall
375
+ // back to a bottom-left placement. Native ignores this (the panel docks to
376
+ // the bottom via the overlay's flex-end).
359
377
  const panelAnchorStyle: ViewStyle | undefined = isWeb
360
378
  ? {
361
379
  position: 'absolute',
362
380
  width: MENU_WIDTH,
363
381
  left: anchor?.left ?? 8,
364
- bottom: anchor?.bottom ?? 8,
382
+ ...(typeof anchor?.top === 'number'
383
+ ? { top: anchor.top }
384
+ : { bottom: anchor?.bottom ?? 8 }),
365
385
  }
366
386
  : undefined;
367
387
 
@@ -376,19 +396,20 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({
376
396
  accessibilityRole="button"
377
397
  accessibilityLabel={t('common.actions.close') || 'Close'}
378
398
  onPress={onClose}
379
- className={isWeb ? 'flex-1 relative' : 'flex-1 justify-end'}
380
- style={!isWeb ? styles.nativeScrim : undefined}
399
+ style={isWeb ? styles.webOverlay : styles.nativeOverlay}
381
400
  >
382
401
  <Pressable
383
402
  // Swallow taps inside the panel so they never reach the overlay's
384
403
  // outside-tap-to-close handler.
385
404
  onPress={() => undefined}
386
- className={
387
- isWeb
388
- ? 'overflow-hidden rounded-2xl border border-border bg-background'
389
- : 'overflow-hidden rounded-t-3xl bg-background pb-3'
390
- }
391
- style={[panelAnchorStyle, { maxHeight: '85%' }, styles.shadow]}
405
+ style={[
406
+ isWeb ? styles.panelWeb : styles.panelNative,
407
+ { backgroundColor: colors.background },
408
+ isWeb && { borderColor: colors.border },
409
+ panelAnchorStyle,
410
+ styles.panelBounds,
411
+ styles.shadow,
412
+ ]}
392
413
  accessibilityRole="menu"
393
414
  accessibilityLabel={t('accountMenu.label') || 'Account menu'}
394
415
  >
@@ -412,36 +433,136 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({
412
433
  const ActionRow: React.FC<{
413
434
  icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'];
414
435
  iconColor: string;
436
+ textColor: string;
415
437
  label: string;
416
438
  disabled: boolean;
417
439
  onPress: () => void;
418
- }> = ({ icon, iconColor, label, disabled, onPress }) => (
440
+ }> = ({ icon, iconColor, textColor, label, disabled, onPress }) => (
419
441
  <Pressable
420
442
  accessibilityRole="menuitem"
421
443
  accessibilityLabel={label}
422
444
  onPress={onPress}
423
445
  disabled={disabled}
424
- className={`flex-row items-center gap-3 px-4 py-3 ${disabled ? 'opacity-40' : ''}`}
446
+ style={[styles.actionRow, disabled && styles.rowDimmed]}
425
447
  >
426
448
  <MaterialCommunityIcons name={icon} size={20} color={iconColor} />
427
- <Text className="font-medium text-foreground">{label}</Text>
449
+ <Text style={[styles.actionText, { color: textColor }]}>{label}</Text>
428
450
  </Pressable>
429
451
  );
430
452
 
431
- // The panel's drop shadow and the native scrim are the only values with no
432
- // NativeWind class equivalent in this package (dynamic elevation + rgba scrim),
433
- // so they stay as small inline objects rather than raw class-replaceable styles.
434
- const styles = {
453
+ const styles = StyleSheet.create({
454
+ // Overlay (`flex-1 relative` web / `flex-1 justify-end` + scrim native).
455
+ webOverlay: {
456
+ flex: 1,
457
+ position: 'relative',
458
+ },
459
+ nativeOverlay: {
460
+ flex: 1,
461
+ justifyContent: 'flex-end',
462
+ backgroundColor: 'rgba(0,0,0,0.32)',
463
+ },
464
+ // Panel shell — web popover (`overflow-hidden rounded-2xl border`).
465
+ panelWeb: {
466
+ overflow: 'hidden',
467
+ borderRadius: 16,
468
+ borderWidth: 1,
469
+ },
470
+ // Panel shell — native bottom sheet (`overflow-hidden rounded-t-3xl pb-3`).
471
+ panelNative: {
472
+ overflow: 'hidden',
473
+ borderTopLeftRadius: 24,
474
+ borderTopRightRadius: 24,
475
+ paddingBottom: 12,
476
+ },
477
+ // Shared `maxHeight: '85%'` cap applied to both panel variants.
478
+ panelBounds: {
479
+ maxHeight: '85%',
480
+ },
481
+ // The panel's drop shadow — dynamic elevation with no class equivalent.
435
482
  shadow: {
436
483
  shadowColor: '#000',
437
484
  shadowOpacity: 0.18,
438
485
  shadowRadius: 24,
439
486
  shadowOffset: { width: 0, height: 8 },
440
487
  elevation: 12,
441
- } satisfies ViewStyle,
442
- nativeScrim: {
443
- backgroundColor: 'rgba(0,0,0,0.32)',
444
- } satisfies ViewStyle,
445
- };
488
+ },
489
+ // Scroll region (`grow-0` + `py-1` content).
490
+ scroll: {
491
+ flexGrow: 0,
492
+ },
493
+ scrollContent: {
494
+ paddingVertical: 4,
495
+ },
496
+ // Account row (`flex-row items-center gap-3 px-4 py-2.5`).
497
+ accountRow: {
498
+ flexDirection: 'row',
499
+ alignItems: 'center',
500
+ gap: 12,
501
+ paddingHorizontal: 16,
502
+ paddingVertical: 10,
503
+ },
504
+ // `opacity-40` dim applied to disabled / non-active-while-switching rows.
505
+ rowDimmed: {
506
+ opacity: 0.4,
507
+ },
508
+ // Identity block (`min-w-0 flex-1`).
509
+ accountInfo: {
510
+ flex: 1,
511
+ minWidth: 0,
512
+ },
513
+ // Inactive account name (`font-medium`).
514
+ accountName: {
515
+ fontWeight: '500',
516
+ },
517
+ // Active account name (`font-semibold`).
518
+ accountNameActive: {
519
+ fontWeight: '600',
520
+ },
521
+ // Secondary email line (`text-xs`).
522
+ accountEmail: {
523
+ fontSize: 12,
524
+ },
525
+ // Per-row sign-out button (`h-7 w-7 items-center justify-center rounded-full opacity-60`).
526
+ rowSignOutButton: {
527
+ width: 28,
528
+ height: 28,
529
+ alignItems: 'center',
530
+ justifyContent: 'center',
531
+ borderRadius: 9999,
532
+ opacity: 0.6,
533
+ },
534
+ // Switching indicator (`flex-row items-center justify-center gap-2 py-2`).
535
+ switchingRow: {
536
+ flexDirection: 'row',
537
+ alignItems: 'center',
538
+ justifyContent: 'center',
539
+ gap: 8,
540
+ paddingVertical: 8,
541
+ },
542
+ switchingText: {
543
+ fontSize: 12,
544
+ fontWeight: '500',
545
+ },
546
+ // Bottom action rows (`flex-row items-center gap-3 px-4 py-3`).
547
+ actionRow: {
548
+ flexDirection: 'row',
549
+ alignItems: 'center',
550
+ gap: 12,
551
+ paddingHorizontal: 16,
552
+ paddingVertical: 12,
553
+ },
554
+ // Sign-out-of-all row shares the action-row geometry.
555
+ signOutAllRow: {
556
+ flexDirection: 'row',
557
+ alignItems: 'center',
558
+ gap: 12,
559
+ paddingHorizontal: 16,
560
+ paddingVertical: 12,
561
+ },
562
+ // Action / sign-out label (`font-medium`).
563
+ actionText: {
564
+ fontWeight: '500',
565
+ },
566
+ });
446
567
 
447
568
  export default ProfileMenu;