Mobile Portal Chrome — Design Spec¶
Status: Shipped · June 2026 (nav chrome, then in-place reactivity) Scope: Member portal mobile navigation (top bar, drawer, bottom tab bar, account sheet) + in-place loading/reactivity Published as mobile app: Yes — WebView wrapper, no browser chrome, no OS back button on iOS
Shipped via: nav chrome (top bar / drawer / account sheet / bottom bar) + content-scoped loading & drawer tap feedback (#14080) + reactive bottom bar — search recents & notifications modal (#14083).
Problem Statement¶
The existing mobile chrome had four gaps:
- No back button. Published as a mobile app (WebView), there are no browser navigation controls. Users on deep pages have no way to go back.
- Outdated hamburger menu. The ≡ button opened a Bootstrap modal — no animation, no slide-in, it just appeared. Looked and felt like 2016.
- Bottom toolbar hidden from anonymous users. Logged-out visitors saw no bottom navigation, which made the portal feel broken on first visit.
- Colorful emoji icons. The nav drawer and bottom bar used emoji icons. Multi-color, inconsistent size, unprofessional on a platform that positions itself as a serious community tool.
A fifth gap surfaced after the chrome shipped: navigation gave no loading feedback. Inside the WebView there is no browser progress bar, so a tap on a nav item or an in-content link led to a dead pause while the server responded. See Loading & In-Place Reactivity below.
Navigation Architecture¶
Native host continuity¶
The native WebView stays on the Raklet host it launched with so the origin-scoped
isNative state is not lost on a custom-domain or subdomain redirect. Server-side
redirect suppression requires a valid Raklet.Mobile cookie with IsNative=true;
the public ?isNative=true query parameter is not sufficient by itself. An explicit
?isNative=false or malformed query value disables the exemption for that request.
Normal browser traffic continues to use permanent redirects to the organisation's
canonical custom domain or {permalink}.raklet.com host.
Top Bar (56px, sticky)¶
| Slot | Root page (Home/Feed) | Deep page (Event, Profile, etc.) |
|---|---|---|
| Left | ≡ hamburger → opens nav drawer | ← back arrow → window.history.back() |
| Center | Org logo (centered) | Org logo (centered) |
| Right | Avatar button → opens account bottom sheet | Avatar button → opens account bottom sheet |
| Right (anon) | Sign In pill button | Sign In pill button |
The left slot is context-aware — one consistent position, two behaviors. The hamburger morphs to ✕ when the drawer is open (CSS-only, 250ms spring animation).
Navigation Drawer (left slide-in)¶
Replaces the old Bootstrap modal (#menuModal).
- Triggered by ≡ on root pages
- Opens with
transform: translateX(-100%) → 0, 300ms spring easing - Scrim backdrop fades in simultaneously:
rgba(0,0,0,0.45) - Close: tap backdrop, tap ✕, or navigate to a page
- Contains: community navigation only (Members, Events, Membership, Feed, etc.)
- Nav items are org API-driven — loaded dynamically, not hardcoded
- Update Navigation link at bottom (admin only)
Account Bottom Sheet (slides up from bottom)¶
Replaces the profile dropdown that was folded into the mobile drawer.
- Triggered by avatar tap (top-right)
- Opens with
transform: translateY(100%) → 0, 320ms spring easing - Contains: Profile · Payments · Tickets · Settings · Administrator Panel · My Organisations · Log Out
Separating community navigation (drawer) from personal account (bottom sheet) mirrors how Discord, Circle, and Notion handle this split.
Bottom Tab Bar (5 tabs, always visible)¶
| Position | Tab | Logged in | Anonymous |
|---|---|---|---|
| 1 | Home | Active, navigates to feed | Same |
| 2 | Search | Opens search modal (autofocus + recent searches) | Same |
| 3 | + (center FAB) | Floating elevated button, opens create post/event | Hidden |
| 4 | Notifications | Bell + unread badge → opens notifications modal (recent items) | Plain link to notifications (sign-in) |
| 5 | Profile / Messages | Opens account bottom sheet / Messages | Replaced by "Sign In" |
Change from previous: Bottom bar was hidden for anonymous users. Now it's always visible — it demonstrates product value before asking for commitment (Discord/Mighty Networks pattern).
Loading & In-Place Reactivity (as-built)¶
Inside the WebView there is no browser progress bar, so navigation has to provide its own feedback, and the chrome should stay put rather than the whole view blanking.
Content-scoped page-loading spinner¶
On a real same-tab navigation, rakletMobileNav.showPageLoading() drops a branded
spinner scoped to the content region — inset below the header (.SocialHeader)
and above the fixed bottom bar (.SocialBottomBarMobile .nav) — so the chrome stays
visible and the navigation reads as "content swapped", not "the whole app blanked".
Falls back to full-bleed if the header isn't found. It is shown synchronously the
instant a nav target is tapped and is discarded when the next page paints (pageshow
clears it for back/forward-cache restores).
Which taps trigger it: drawer items, account-sheet items, bottom-bar tabs, and
in-content links inside .SocialContent (event cards, member profiles, etc.).
Gated to the mobile viewport (max-width: 767px); desktop is unchanged. A
same-tab-HTTP(S) allowlist (a.protocol) plus exclusions for target=_blank,
data-toggle=modal, download, rel~="external", data-ajax/data-remote, and
cross-origin links keep the spinner off taps that don't end in a page load.
Drawer item tap — orchestrated feedback¶
Tapping a drawer/menu item: the row is marked selected immediately (reuses the
.list-group-item.active brand tint), the spinner is shown, then the drawer slides
closed a beat (~130ms) later — so the wait reads as selected → closing → loading
instead of a dead pause.
Search modal — autofocus + recent searches¶
The Search tab opens #searchModal. The input autofocuses on open so the keyboard
appears (best-effort on iOS WebView, which only raises the keyboard for gesture-stack
focus; reliable on Android). Recent searches are kept in localStorage, namespaced
per org (raklet:recentSearches:<permalink>, cap 8): tap a term to re-run it, ×
to remove a single entry.
Notifications modal — reuse the desktop AJAX¶
The Notifications tab opens #mobileNotificationModal instead of navigating to the
full page. It lazy-loads recent items from the same endpoint as the desktop header
dropdown (GET /App/Notification/GetUserNotifications), renders avatar / actor /
description / relative time with an unread highlight, marks unread items read on tap
(SetReadNotification, via sendBeacon so it survives the navigation), supports
"mark all read" (SetReadAllNotification), and links to the full page via "View all".
Still a full reload¶
The Messages tab still navigates to the full /App/Messages page — deferred for
a later rebuild.
Animation Specification¶
All animations use transform only — never width, left, right, or height. This keeps them on the GPU compositor thread.
| Element | Property | Duration | Easing |
|---|---|---|---|
| Drawer open | translateX(-100%) → 0 |
300ms | cubic-bezier(0.16, 1, 0.3, 1) |
| Drawer close | translateX(0) → -100%) |
220ms | cubic-bezier(0.4, 0, 1, 1) |
| Sheet open | translateY(100%) → 0 |
320ms | cubic-bezier(0.16, 1, 0.3, 1) |
| Sheet close | translateY(0) → 100% |
240ms | cubic-bezier(0.4, 0, 1, 1) |
| Backdrop | opacity 0 → 0.45 |
matches drawer/sheet | same easing |
| Hamburger → ✕ | line transform | 250ms | cubic-bezier(0.16, 1, 0.3, 1) |
| Page-loading overlay | opacity 0 → 1 |
160ms | ease |
Icon System¶
| Attribute | Value |
|---|---|
| Style | SVG stroke/outline |
| Stroke width | 2px |
| Stroke cap | round |
| Size (bottom bar) | 22 × 22px |
| Size (drawer) | 16 × 16px in 32px container |
| Color inactive | #9CA3AF (neutral) |
| Color active | Brand accent (CSS variable, org-overridable) |
No emoji. No Font Awesome color icons. One color per icon.
Color & Typography¶
Colors are CSS variables to support org-level theming:
--portal-brand: #1B2035; /* top bar bg, drawer header — org can override */
--portal-accent: #1A6EF5; /* active tabs, FAB, active drawer item — org can override */
--portal-surface: #F0F2F5;
--portal-card: #FFFFFF;
--portal-muted: #6B7280;
--portal-border: #E5E7EB;
--portal-badge: #EF4444;
Typography: DM Sans as platform default (replaces system-ui). Org font setting overrides via --org-font CSS variable. Fallback: -apple-system, sans-serif.
Files Affected¶
| File | Change |
|---|---|
Application/Views/Shared/_Layout.cshtml |
Top bar; 5-tab bottom bar; search modal (recent-searches container, data-permalink); notifications modal (#mobileNotificationModal, endpoint data-*) |
Application/Views/Shared/_NavigationMobile.cshtml |
Slide drawer (replaces the Bootstrap modal) |
Raklet.Backend/Content/styles/core/social/layout/social-header.scss |
Hamburger morph animation, back button style |
Raklet.Backend/Content/styles/core/social/layout/social-footer.scss |
5-tab layout, FAB styles, remove anon display:none |
Raklet.Backend/Content/styles/core/social/pages/social-mobile-navigation.scss |
Slide drawer + backdrop; content-scoped #mobilePageLoading; .RecentSearches*; .MobileNotif* |
Raklet.Backend/Content/scripts/core/social/mvc/social.js |
rakletMobileNav (drawer/sheet/back, page-loading, nav-tap feedback), rakletMobileSearch, rakletMobileNotifications |
Resources/Strings.resx (+ .Designer.cs) |
RecentSearches string |
What This Is Not¶
- Not a full portal reskin — only the navigation chrome plus the loading/reactivity behaviors above. Page content styles are otherwise unchanged.
- Not iOS/Android native code — this is mobile web, served via WebView.
- Not a new font for all pages — DM Sans is the mobile chrome UI font (nav labels, sheet items). Page-level fonts remain under org font settings.
- Not a full SPA — navigation is still server-rendered page loads; the spinner + chrome-stays-put treatment give the feel of in-place content swaps without an AJAX rewrite. Search and notifications are the genuinely in-place (modal) interactions; Messages is still a full reload.