chore: add system docs, specs, and misc updates from other sessions
- Add complete docs/systems/ reference (18 system docs) - Add federation relay status doc and prior spec/plan docs - Remove superseded docs/federation-dm-s2s.md (replaced by docs/systems/federation.md) - CLAUDE.md updates - Minor fixes in social.ts, types.ts, AddDmMemberModal, NewDmModal, UserSettings
This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
# Mobile & Responsive UI System
|
||||
|
||||
Source files:
|
||||
- `packages/web/src/components/layout/MobileShell.tsx` — Root mobile container: three tabs, screen stack, swipe gesture, browser history sync
|
||||
- `packages/web/src/components/layout/MobileScreenStack.tsx` — Push/pop animation state machine with CSS slide transitions
|
||||
- `packages/web/src/components/layout/MobileBottomNav.tsx` — Tab bar with unread badge counts, hidden when stack non-empty
|
||||
- `packages/web/src/components/layout/MobileNav.tsx` — Legacy hamburger menu (pre-MobileShell), renders only when `isMobile` true
|
||||
- `packages/web/src/components/layout/MobileScreenHeader.tsx` — Reusable back-arrow header for pushed screens
|
||||
- `packages/web/src/components/layout/MobileChatScreen.tsx` — Channel/DM chat view with MessageList, MessageInput, TypingIndicator
|
||||
- `packages/web/src/components/layout/MobileDmsScreen.tsx` — DM list with online friends row, unread indicators, FAB for new DM
|
||||
- `packages/web/src/components/layout/MobileSpacesScreen.tsx` — Space strip + channel list (split-pane), folder support, voice user rows
|
||||
- `packages/web/src/components/layout/MobileYouScreen.tsx` — User profile card, action rows, logout
|
||||
- `packages/web/src/components/layout/MobileSettingsScreen.tsx` — Settings hub and direct-panel rendering via `initialPanel` prop
|
||||
- `packages/web/src/components/layout/MobileInstancePanel.tsx` — Admin-only instance settings hub (General, Streaming, Storage, Users)
|
||||
- `packages/web/src/components/layout/MobileMembersScreen.tsx` — Space member list grouped by role, with activity cards
|
||||
- `packages/web/src/components/layout/MobileVoiceFullScreen.tsx` — Full-screen voice call view with participant grid and control bar
|
||||
- `packages/web/src/components/layout/MobileVoiceMiniBar.tsx` — Persistent mini-bar overlay during voice calls
|
||||
- `packages/web/src/components/layout/MobileFolderSheet.tsx` — Bottom sheet for space folder contents, rename, color, ungroup
|
||||
- `packages/web/src/hooks/useSwipeGesture.ts` — Edge swipe-back touch gesture hook
|
||||
- `packages/web/src/stores/uiStore.ts` — Mobile navigation state (mobileScreen, mobileStack, push/pop actions)
|
||||
|
||||
Cross-references:
|
||||
- Surface/glass tiers, animations, input classes: see `docs/systems/design-system.md`
|
||||
- Voice call state machine, LiveKit integration: see `docs/systems/voice.md`
|
||||
- Desktop three-column layout (AppLayout): see `docs/systems/design-system.md`
|
||||
|
||||
---
|
||||
|
||||
## Responsive Breakpoint
|
||||
|
||||
Detection is in `AppLayout.tsx`:
|
||||
|
||||
```ts
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||
// Called on mount + resize listener
|
||||
```
|
||||
|
||||
| Breakpoint | Value | Layout |
|
||||
|------------|-------|--------|
|
||||
| Desktop | `>= 768px` | AppLayout three-column grid (sidebar + chat + member list) |
|
||||
| Mobile | `< 768px` | MobileShell (tab bar + screen stack) |
|
||||
|
||||
`AppLayout` conditionally renders `<MobileShell />` when `isMobile === true`. Modals render globally in both modes.
|
||||
|
||||
### Desktop-to-Mobile Transition (`uiStore:setIsMobile`)
|
||||
|
||||
| Direction | State changes |
|
||||
|-----------|--------------|
|
||||
| **To mobile** (`isMobile: true`) | `sidebarOpen: false`, `memberListOpen: false` |
|
||||
| **To desktop** (`isMobile: false`) | `sidebarOpen: true`, `mobileScreen: 'spaces'`, `mobileStack: []` (memberListOpen retains its persisted value) |
|
||||
|
||||
The `setIsMobile` function is a no-op if the value hasn't changed (`prev === isMobile` guard).
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
MobileShell (100dvh flex column)
|
||||
+-- MobileScreenStack (flex-1, relative, overflow-hidden)
|
||||
| +-- Root screen (spaces | dms | you) — always rendered, visibility-hidden when covered
|
||||
| +-- Stacked screens (absolute inset-0, bg-surface-base, z-10)
|
||||
+-- MobileVoiceMiniBar (conditional: when currentVoiceChannelId && voice-full not on top)
|
||||
+-- MobileBottomNav (glass-bubble tab bar, hidden when stack non-empty)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile Navigation State (`uiStore`)
|
||||
|
||||
### Data Types
|
||||
|
||||
```ts
|
||||
interface MobileStackEntry {
|
||||
screen: string; // Screen key from screenMap
|
||||
params?: Record<string, string>; // e.g., { channelId, spaceId }
|
||||
}
|
||||
|
||||
// State
|
||||
mobileScreen: 'spaces' | 'dms' | 'you'; // Active root tab
|
||||
mobileStack: MobileStackEntry[]; // Push/pop stack
|
||||
```
|
||||
|
||||
### Actions
|
||||
|
||||
| Action | Behavior |
|
||||
|--------|----------|
|
||||
| `setMobileTab(tab)` | Sets `mobileScreen` to tab, clears `mobileStack` to `[]` |
|
||||
| `pushMobileScreen(screen, params?)` | Appends entry to `mobileStack`, calls `history.pushState({ mobileScreen: screen }, '')` |
|
||||
| `popMobileScreen()` | Removes last entry from `mobileStack` (no-op if empty). Does NOT call `history.back()` |
|
||||
|
||||
### Browser History Integration
|
||||
|
||||
`pushMobileScreen` calls `history.pushState` to add a browser history entry. `MobileShell` listens for `popstate` events:
|
||||
|
||||
```ts
|
||||
// MobileShell.tsx
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
if (useUIStore.getState().mobileStack.length > 0) {
|
||||
popMobileScreen();
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [popMobileScreen]);
|
||||
```
|
||||
|
||||
This means the hardware/browser back button pops the mobile screen stack. `popMobileScreen` intentionally does not call `history.back()` to avoid infinite loops when triggered by the `popstate` handler.
|
||||
|
||||
### Deep Link Reconstruction
|
||||
|
||||
On mount, `MobileShell` checks `location.pathname` for `/channels/:spaceId/:channelId` and pushes a `channel-chat` screen if the stack is empty:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
const path = location.pathname;
|
||||
const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
|
||||
if (match && mobileStack.length === 0) {
|
||||
pushMobileScreen('channel-chat', { channelId, spaceId });
|
||||
}
|
||||
}, []); // Mount only
|
||||
```
|
||||
|
||||
### User Profile Mobile Override
|
||||
|
||||
`uiStore:openUserProfile` detects `isMobile` and pushes a `user-profile` screen instead of showing a positioned popout:
|
||||
|
||||
```ts
|
||||
if (get().isMobile) {
|
||||
set((state) => ({
|
||||
mobileStack: [...state.mobileStack, { screen: 'user-profile', params: { userId: user.id } }],
|
||||
}));
|
||||
history.pushState({ mobileScreen: 'user-profile' }, '');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MobileScreenStack — Animation State Machine
|
||||
|
||||
File: `MobileScreenStack.tsx`
|
||||
|
||||
The stack manages CSS slide-in/slide-out animations using a dual-state approach: the canonical `mobileStack` (from uiStore) drives transitions, while `renderStack` (local state) controls what's actually rendered.
|
||||
|
||||
### State
|
||||
|
||||
```ts
|
||||
const [transitioning, setTransitioning] = useState<'push' | 'pop' | null>(null);
|
||||
const [renderStack, setRenderStack] = useState(mobileStack);
|
||||
const prevStackRef = useRef(mobileStack);
|
||||
const animatingRef = useRef(false);
|
||||
```
|
||||
|
||||
### Transition Algorithm
|
||||
|
||||
The `useEffect` on `mobileStack` compares the new length to the previous length:
|
||||
|
||||
**Push (newLen > prevLen):**
|
||||
1. Set `renderStack = mobileStack` (new screen enters the DOM)
|
||||
2. Set `transitioning = 'push'` (new screen positioned at `translateX(100%)` — off-screen right)
|
||||
3. Double `requestAnimationFrame` ensures the off-screen position is painted
|
||||
4. Set `transitioning = null` (CSS transition kicks in, slides screen to `translateX(0)`)
|
||||
|
||||
**Pop (newLen < prevLen):**
|
||||
1. Set `transitioning = 'pop'` (top screen gets `transition-transform duration-200 ease-out` + `translateX(100%)`)
|
||||
2. After 200ms timeout: set `renderStack = mobileStack` (removed screen exits DOM), `transitioning = null`
|
||||
|
||||
**Same length (replacement):**
|
||||
- Directly set `renderStack = mobileStack` (no animation)
|
||||
|
||||
### CSS Classes per Screen State
|
||||
|
||||
| Condition | Classes | Transform |
|
||||
|-----------|---------|-----------|
|
||||
| Top screen, `transitioning === 'push'` | `absolute inset-0 bg-surface-base z-10` | `translateX(100%)` |
|
||||
| Top screen, `transitioning === 'pop'` | `... transition-transform duration-200 ease-out` | `translateX(100%)` |
|
||||
| Top screen, settled (no transition) | `... transition-transform duration-200 ease-out` | (none, defaults to 0) |
|
||||
| Non-top screen | `absolute inset-0 bg-surface-base z-10` | (none) |
|
||||
|
||||
### Root Screen Visibility
|
||||
|
||||
The root screen (spaces/dms/you) is always rendered but has `visibility: hidden` when `renderStack.length > 0`. This avoids unmount/remount when returning to root.
|
||||
|
||||
### Animation Timing
|
||||
|
||||
| Phase | Duration | Mechanism |
|
||||
|-------|----------|-----------|
|
||||
| Push: off-screen paint | ~2 frames (via double rAF) | `requestAnimationFrame` x2 |
|
||||
| Push: slide in | 200ms | CSS `transition-transform duration-200 ease-out` |
|
||||
| Pop: slide out | 200ms | CSS `transition-transform duration-200 ease-out` |
|
||||
| Pop: DOM cleanup | 200ms | `setTimeout(200)` after which `renderStack` is updated |
|
||||
|
||||
---
|
||||
|
||||
## MobileBottomNav — Tab Bar
|
||||
|
||||
File: `MobileBottomNav.tsx`
|
||||
|
||||
### Visibility
|
||||
|
||||
Returns `null` when `mobileStack.length > 0` — hidden whenever a pushed screen is active.
|
||||
|
||||
### Tabs
|
||||
|
||||
| Tab | Badge Type | Badge Source |
|
||||
|-----|-----------|--------------|
|
||||
| Spaces | Dot (red) | `unreadChannels` has any non-voice channel |
|
||||
| DMs | Numeric count | Count of DM channels where `lastMessage.id > readStates[dmId]` |
|
||||
| You | Dot (red) | Pending incoming friend requests (`status === 'pending'` and `fromId !== authUser.id`) |
|
||||
|
||||
Badge caps at `99+` for numeric badges.
|
||||
|
||||
### Tab Tap Behavior
|
||||
|
||||
| Tab | Navigation |
|
||||
|-----|------------|
|
||||
| Spaces | Navigates to last known space route, or `/` |
|
||||
| DMs | Navigates to `/channels/@me` |
|
||||
| You | No navigation (stays on current route) |
|
||||
|
||||
All tabs call `setMobileTab(tab)` which clears the mobile stack.
|
||||
|
||||
### Styling
|
||||
|
||||
- Container: `glass-bubble` surface tier
|
||||
- Height: `calc(56px + env(safe-area-inset-bottom))`
|
||||
- Active tab: `text-accent-primary`; Inactive: `text-txt-secondary`
|
||||
|
||||
---
|
||||
|
||||
## Edge Swipe-Back Gesture (`useSwipeGesture`)
|
||||
|
||||
File: `hooks/useSwipeGesture.ts`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Param | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `onSwipeRight` | — | Callback fired on successful swipe |
|
||||
| `edgeThreshold` | `20` (px) | Touch must start within this distance from the left edge |
|
||||
| `swipeThreshold` | `50` (px) | Horizontal movement required to trigger |
|
||||
| `enabled` | `true` | Disables all event listeners when false |
|
||||
|
||||
### Algorithm
|
||||
|
||||
1. `touchstart`: If `touch.clientX <= 20`, record start position
|
||||
2. `touchmove`: If vertical movement exceeds horizontal (and not already swiping), cancel. If horizontal dx > 50px, set swiping flag and `preventDefault()`
|
||||
3. `touchend` / `touchcancel`: If swiping flag is set, fire `onSwipeRight`
|
||||
|
||||
### Usage in MobileShell
|
||||
|
||||
```ts
|
||||
useSwipeGesture({
|
||||
onSwipeRight: () => {
|
||||
if (mobileStack.length > 0) popMobileScreen();
|
||||
},
|
||||
enabled: mobileStack.length > 0,
|
||||
});
|
||||
```
|
||||
|
||||
Only active when there are pushed screens to pop. Document-level event listeners are added/removed based on `enabled`.
|
||||
|
||||
Event options: `touchstart` is `{ passive: true }`, `touchmove` is `{ passive: false }` (allows `preventDefault` to block scroll during swipe).
|
||||
|
||||
---
|
||||
|
||||
## Screen Map
|
||||
|
||||
`MobileShell` defines a `screenMap` that maps screen keys to render functions:
|
||||
|
||||
| Screen Key | Component | Params |
|
||||
|------------|-----------|--------|
|
||||
| `channel-chat` | `MobileChatScreen` | `{ channelId, spaceId }` |
|
||||
| `friends` | `FriendsPage` (with `mobile` prop) | — |
|
||||
| `settings` | `MobileSettingsScreen` | — |
|
||||
| `settings-account` | `MobileSettingsScreen` | `initialPanel="account"` |
|
||||
| `settings-voice` | `MobileSettingsScreen` | `initialPanel="voice"` |
|
||||
| `settings-privacy` | `MobileSettingsScreen` | `initialPanel="privacy"` |
|
||||
| `settings-connections` | `MobileSettingsScreen` | `initialPanel="connections"` |
|
||||
| `settings-instance` | `MobileInstancePanel` | — |
|
||||
| `settings-instance-general` | `GeneralPanel` (wrapped) | — |
|
||||
| `settings-instance-streaming` | `StreamingPanel` (wrapped) | — |
|
||||
| `settings-instance-storage` | `StoragePanel` (wrapped) | — |
|
||||
| `settings-instance-users` | `UsersPanel` (wrapped) | — |
|
||||
| `members` | `MobileMembersScreen` | `{ spaceId? }` |
|
||||
| `voice-full` | `MobileVoiceFullScreen` | — |
|
||||
| `explore` | `ExplorePage` | — |
|
||||
| `user-profile` | `UserProfileModal` | `{ userId }` (opens modal via `openModal('userProfile', ...)`) |
|
||||
|
||||
Instance settings sub-panels (`settings-instance-*`) are wrapped inline with `MobileScreenHeader` + scrollable container + `bg-surface-base`.
|
||||
|
||||
---
|
||||
|
||||
## Root Screens
|
||||
|
||||
### MobileSpacesScreen
|
||||
|
||||
Split-pane layout: 60px `glass-strip` space strip on the left + channel list on the right.
|
||||
|
||||
**Space strip features:**
|
||||
- Home/DMs button at top (navigates to DMs tab)
|
||||
- Folder-aware layout via `spaceLayout` and `folders` from spaceStore
|
||||
- Unread pill indicator on left edge (8px dot for unread, 32px bar for selected)
|
||||
- Federation badge on space icons (globe icon, amber dot if disconnected)
|
||||
- Context menu: Invite, Create Folder, Move to Folder, Remove from Folder, Transfer Ownership, Leave
|
||||
- Add Space button at bottom (opens bottom sheet: Create / Join / Explore)
|
||||
|
||||
**Channel list features:**
|
||||
- Channels grouped by categories (collapsible)
|
||||
- Uncategorized channels rendered first
|
||||
- Text channels: `#` prefix, unread dot, selected highlight
|
||||
- Voice channels: speaker icon, inline `VoiceUserRow` for connected users with context menus
|
||||
- Voice channel tap opens `MobileVoiceJoinSheet` (not direct join)
|
||||
- Text channel tap: navigates via router + pushes `channel-chat` screen
|
||||
- Channel/category context menus for management (guarded by `MANAGE_CHANNELS` permission)
|
||||
|
||||
**Layout resolution:**
|
||||
- `spaceLayout` array items can be `{ t: 's', id }` (space) or `{ t: 'f', id }` (folder)
|
||||
- Spaces not in the layout are appended at the end
|
||||
- Folder items render as folder icon buttons that open `MobileFolderSheet`
|
||||
|
||||
### MobileDmsScreen
|
||||
|
||||
- Header: "Messages" title + "Friends" button
|
||||
- Online friends activity row (horizontal scroll, shows avatar + status dot)
|
||||
- DM list sorted by last message time (newest first)
|
||||
- Each DM row: avatar, name, message preview, timestamp, unread dot
|
||||
- Group DMs: group icon instead of avatar, context menu with "Leave Group"
|
||||
- Federated users: `@domain` subtitle below username
|
||||
- Empty state: sleeping mascot
|
||||
- FAB: New DM button (opens `newDm` modal), positioned `bottom-20 right-4`
|
||||
|
||||
### MobileYouScreen
|
||||
|
||||
- Settings gear in header (pushes `settings`)
|
||||
- Profile card: banner/accent background, avatar (-10 overlap), display name, username, custom status, bio
|
||||
- Action rows (each pushes a settings sub-screen): Edit Profile, Friends, Connections, Voice & Audio
|
||||
- Log Out button with `ConfirmDialog`
|
||||
|
||||
---
|
||||
|
||||
## Pushed Screen Components
|
||||
|
||||
### MobileChatScreen
|
||||
|
||||
Params: `{ channelId, spaceId }`
|
||||
|
||||
- Loads messages and sets current channel on mount via `useChatStore`
|
||||
- Resolves channel name: DM names from member list (group: comma-separated), space channels by `#name`
|
||||
- Custom header with back button + channel name + members button (space channels only)
|
||||
- Members button pushes `members` screen (not shown for DMs)
|
||||
- Renders `MessageList`, `TypingIndicator`, `MessageInput`
|
||||
|
||||
### MobileSettingsScreen
|
||||
|
||||
Two modes controlled by `initialPanel` prop:
|
||||
|
||||
1. **Hub mode** (`initialPanel` undefined): List of setting sections (Account, Voice & Audio, Privacy, Connections, Instance for admins). Each pushes `settings-{id}`.
|
||||
2. **Direct panel mode** (`initialPanel` set): Renders the corresponding panel component (AccountPanel, VoicePanel, PrivacyPanel, ConnectionsPanel) directly with a back header.
|
||||
|
||||
### MobileInstancePanel
|
||||
|
||||
Admin-only instance settings hub. Pre-fetches instance settings and streaming limits on mount. Lists four sub-sections (General, Streaming, Storage, Users), each pushing `settings-instance-{id}`.
|
||||
|
||||
### MobileMembersScreen
|
||||
|
||||
Params: `{ spaceId? }` (falls back to `currentSpaceId`)
|
||||
|
||||
- Groups online members by their highest-positioned role
|
||||
- Owner gets special `__owner__` group (position Infinity)
|
||||
- Offline members in separate section
|
||||
- Each member row: avatar with status, role-colored name, federated domain, activity card
|
||||
- Tap opens `user-profile` screen
|
||||
|
||||
Member group resolution (`getMemberGroup`):
|
||||
1. Owner: `{ key: '__owner__', label: 'OWNER', position: Infinity }`
|
||||
2. Has roles: top role by position `{ key: roleId, label: ROLE_NAME, position }`
|
||||
3. No roles: `{ key: '__online__', label: 'ONLINE', position: -1 }`
|
||||
|
||||
### MobileScreenHeader
|
||||
|
||||
Reusable header component used by `MobileInstancePanel`, `MobileMembersScreen`, and inline in screenMap wrappers.
|
||||
|
||||
```ts
|
||||
interface MobileScreenHeaderProps {
|
||||
title: string;
|
||||
rightActions?: React.ReactNode;
|
||||
}
|
||||
```
|
||||
|
||||
- Height: 48px (`h-12`)
|
||||
- Back button calls `popMobileScreen()`
|
||||
- Bottom border: `border-border-soft`
|
||||
- Background: `bg-surface-base`
|
||||
|
||||
---
|
||||
|
||||
## Voice Overlay
|
||||
|
||||
### MobileVoiceMiniBar
|
||||
|
||||
File: `MobileVoiceMiniBar.tsx`
|
||||
|
||||
**Visibility rules:**
|
||||
- Shown when `currentVoiceChannelId` is truthy
|
||||
- Hidden when `voice-full` is the top screen in `mobileStack`
|
||||
|
||||
**Layout:** `glass-bubble` container, `mx-2 mb-1 rounded-2xl`. Positioned between `MobileScreenStack` and `MobileBottomNav` in the DOM.
|
||||
|
||||
**Content:**
|
||||
- Left: mint circle icon + channel name + participant count (tap expands to `voice-full`)
|
||||
- Right: mute toggle, deafen toggle, disconnect button
|
||||
- Quick controls use `e.stopPropagation()` to prevent expanding on control taps
|
||||
|
||||
**Disconnect logic:** Handles both DM calls (`dm_call_end` WS event) and space voice channels (`voice_leave` WS event), calls `disconnectFn`, clears `activeDmCall`.
|
||||
|
||||
### MobileVoiceFullScreen
|
||||
|
||||
File: `MobileVoiceFullScreen.tsx`
|
||||
|
||||
**Header:** Collapse chevron (down arrow, pops screen), channel name, space name subtitle, participant count, members button (space channels only).
|
||||
|
||||
**Participant grid:**
|
||||
- `grid-cols-1` for 1-2 participants, `grid-cols-2` for 3+
|
||||
- Avatar size: 80px for 1-2 participants, 56px for 3+
|
||||
- Mute/deafen badge overlay on avatar (bottom-right, rose circle with icon)
|
||||
- Shows self-mute, space mute, permission mute, self-deafen, space deafen
|
||||
- Context menu on other participants: voice mod items, local mute checkbox, volume slider
|
||||
|
||||
**Control bar:** `glass-bubble` container with safe area padding.
|
||||
|
||||
| Button | State Colors |
|
||||
|--------|-------------|
|
||||
| Mute | Active: `bg-accent-rose/20 text-accent-rose`, Inactive: `bg-surface-elevated text-txt-primary` |
|
||||
| Deafen | Same as mute |
|
||||
| Camera | Active: `bg-accent-mint/20 text-accent-mint`, Inactive: same |
|
||||
| Screen share | Same as camera |
|
||||
| Disconnect | Always `bg-accent-rose text-white` |
|
||||
|
||||
**Disconnect:** Same logic as mini-bar (handles DM calls and space voice, calls `disconnectFn`, pops screen).
|
||||
|
||||
**Guard:** If `currentVoiceChannelId` is falsy, calls `popMobileScreen()` and returns null.
|
||||
|
||||
---
|
||||
|
||||
## MobileFolderSheet
|
||||
|
||||
File: `MobileFolderSheet.tsx`
|
||||
|
||||
Bottom sheet for viewing and managing space folders.
|
||||
|
||||
**Presentation:**
|
||||
- Fixed overlay: `z-[300]` backdrop + `z-[301]` sheet
|
||||
- Glass: `glass-modal` surface tier
|
||||
- Animation: `animate-slide-up-sheet` (200ms ease-out translateY)
|
||||
- Max height: `60vh`
|
||||
- Drag handle: 10x1 rounded pill
|
||||
|
||||
**Props:**
|
||||
|
||||
```ts
|
||||
interface MobileFolderSheetProps {
|
||||
folder: SpaceFolder;
|
||||
onClose: () => void;
|
||||
onSelectSpace: (spaceId: string) => void;
|
||||
onUpdateFolder: (folderId: string, updates: { name?: string | null; color?: string | null }) => void;
|
||||
onUngroup: (folderId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Folder header with color swatch, name (inline editable), space count
|
||||
- Context menu: Rename, Color picker (7 accent colors + clear), Ungroup (danger)
|
||||
- Space list with gradient/icon thumbnails, tap selects space and closes sheet
|
||||
|
||||
**Folder colors:**
|
||||
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| Mint | `rgb(var(--accent-mint))` |
|
||||
| Peach | `rgb(var(--accent-peach))` |
|
||||
| Lavender | `rgb(var(--accent-lavender))` |
|
||||
| Sky | `rgb(var(--accent-sky))` |
|
||||
| Amber | `rgb(var(--accent-amber))` |
|
||||
| Rose | `rgb(var(--accent-rose))` |
|
||||
| Coral | `rgb(var(--accent-coral))` |
|
||||
|
||||
---
|
||||
|
||||
## MobileNav (Legacy)
|
||||
|
||||
File: `MobileNav.tsx`
|
||||
|
||||
A hamburger-menu component that predates `MobileShell`. It renders a fixed-position toggle button (`z-[120]`) and a backdrop overlay (`z-[35]`). Only renders when `isMobile === true` (via `if (!isMobile) return null`).
|
||||
|
||||
This component provides sidebar toggle functionality for contexts where `MobileShell` is not the active layout. It is separate from the MobileShell tab-based navigation.
|
||||
|
||||
---
|
||||
|
||||
## Safe Area Handling
|
||||
|
||||
Several mobile components respect the iOS safe area inset:
|
||||
|
||||
| Component | CSS |
|
||||
|-----------|-----|
|
||||
| MobileBottomNav | `paddingBottom: env(safe-area-inset-bottom)`, height includes inset |
|
||||
| MobileVoiceFullScreen control bar | `marginBottom: calc(0.5rem + env(safe-area-inset-bottom))` |
|
||||
| MobileFolderSheet | `paddingBottom: env(safe-area-inset-bottom)` |
|
||||
| MobileSpacesScreen add sheet | `paddingBottom: env(safe-area-inset-bottom)` |
|
||||
|
||||
The root MobileShell uses `height: 100dvh` (dynamic viewport height) to account for mobile browser chrome.
|
||||
|
||||
---
|
||||
|
||||
## Z-Index Layers
|
||||
|
||||
| Layer | Z-Index | Component |
|
||||
|-------|---------|-----------|
|
||||
| Stacked screens | `z-10` | MobileScreenStack pushed screens |
|
||||
| MobileNav backdrop | `z-[35]` | MobileNav sidebar overlay |
|
||||
| MobileNav hamburger | `z-[120]` | MobileNav toggle button |
|
||||
| DMs FAB | `z-20` | MobileDmsScreen new DM button |
|
||||
| Bottom sheets (backdrop) | `z-[300]` | MobileFolderSheet, Add Space sheet, ContextMenu |
|
||||
| Bottom sheets (content) | `z-[301]` | MobileFolderSheet, Add Space sheet, ContextMenu |
|
||||
|
||||
---
|
||||
|
||||
## LocalStorage Persistence
|
||||
|
||||
The uiStore uses `zustand/persist` with `partialize`:
|
||||
|
||||
```ts
|
||||
partialize: (state) => ({
|
||||
memberListOpen: state.memberListOpen,
|
||||
lastChannelPerSpace: state.lastChannelPerSpace,
|
||||
})
|
||||
```
|
||||
|
||||
Only `memberListOpen` and `lastChannelPerSpace` persist. Mobile navigation state (`mobileScreen`, `mobileStack`) is ephemeral and resets on page reload.
|
||||
|
||||
`lastChannelPerSpace` is used by `MobileBottomNav` to navigate to the last-viewed channel when the Spaces tab is tapped, and by `MobileSpacesScreen` when a text channel is opened (via `setLastChannel`).
|
||||
Reference in New Issue
Block a user