Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
658 changes: 658 additions & 0 deletions docs/superpowers/plans/2026-04-03-mobile-first-layout-redesign.md

Large diffs are not rendered by default.

113 changes: 113 additions & 0 deletions docs/superpowers/specs/2026-04-03-mobile-first-layout-redesign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Mobile-First Layout Redesign

## Problem

The three Blazor SSR layouts (AppLayout, PublicLayout, ManageLayout) lack proper mobile navigation. On mobile:

- **AppLayout**: Sidebar is hidden off-screen with CSS (`translateX(-100%)`) but there is no hamburger button, no backdrop element, and no way to open it. Navigation is completely inaccessible.
- **PublicLayout**: Nav items use `hidden sm:flex` and disappear entirely on mobile with no alternative.
- **ManageLayout**: Stacks the sidebar nav above content vertically, consuming significant vertical space before the user reaches actual content.

## Design

### 1. AppLayout — Hamburger + Swipe Gesture Sidebar

**Mobile (< 768px):**

- Add a **sticky top header** (`.app-mobile-header` — CSS class already defined in theme.css) containing:
- Hamburger icon button (left) with `aria-expanded` + `aria-label="Open navigation"` — toggles sidebar open/closed
- App logo + "SimpleModule" text (left of center)
- Add a **backdrop element** (`.app-sidebar-backdrop`, `aria-hidden="true"`) to the DOM — clicking it closes the sidebar
- **Swipe gesture support**:
- Swipe right from left edge (touch start X ≤ 25px) opens the sidebar
- Swipe left on the sidebar or backdrop closes it
- Minimum swipe distance: 50px to trigger
- Note: iOS Safari reserves left-edge swipe for back navigation; this gesture is supplementary to the hamburger button and may not fire on all browsers
- Sidebar opens with `app-sidebar-open` class (CSS already defines `translateX(0)`)
- Sidebar collapse toggle button **hidden on mobile** — add `hidden md:block` to `.app-sidebar-toggle`
- **Body scroll lock**: toggle `overflow: hidden` on `document.body` when sidebar is open
- **Escape key**: close sidebar on `Escape` keypress
- **Resize cleanup**: `matchMedia('(min-width: 768px)')` listener clears mobile sidebar state when crossing to desktop

**Desktop (>= 768px):**
- No changes. Existing collapse/expand behavior preserved.

**Files to modify:**
- `framework/SimpleModule.Blazor/Components/Layout/AppLayout.razor` — add mobile header + backdrop HTML, update inline JS
- `template/SimpleModule.Host/wwwroot/js/shell.js` — add swipe gesture handler, mobile sidebar toggle, body scroll lock, escape key, matchMedia listener
- `packages/SimpleModule.Theme.Default/theme.css` — hide collapse toggle on mobile, `prefers-reduced-motion` rule

### 2. PublicLayout — Full-Screen Overlay Menu

**Mobile (< 768px):**

- Add a **hamburger icon button** visible only on mobile (`md:hidden`) with `aria-expanded` + `aria-label`
- Tapping opens a **full-screen overlay** (`fixed inset-0 z-50`, `role="dialog"`, `aria-modal="true"`):
- Background matches surface color with fade-in animation
- Close button (X icon) in top-right corner
- Nav items rendered as large, touch-friendly links (`py-3 text-lg`, min 44px touch target)
- Nested dropdown children become **expandable accordion sections** with chevron toggle
- Login/Sign up buttons at the bottom of the overlay
- **Focus trap**: vanilla JS traps Tab/Shift+Tab within the overlay while open
- **Body scroll lock**: `overflow: hidden` on body while open
- **Escape key**: closes overlay
- **Dual markup approach**: desktop hover dropdowns (`hidden md:block`) and mobile accordion nav (`md:hidden`) are separate markup trees — keeps CSS-only hover behavior on desktop while allowing click-driven accordions on mobile. Matches existing `hidden sm:flex` pattern already in PublicLayout.
- Overlay state managed via inline JS in PublicLayout.razor (matches existing pattern of layout-specific JS inline)

**Desktop (>= 768px):**
- No changes. Existing hover dropdowns preserved.

**Files to modify:**
- `framework/SimpleModule.Blazor/Components/Layout/PublicLayout.razor` — add hamburger button + overlay HTML + mobile accordion markup + inline JS

### 3. ManageLayout — Horizontal Scrollable Tabs

**Mobile (< 768px):**

- Replace the vertical nav sidebar with a **horizontal scrollable tab bar**:
- Sits below the "Account Settings" heading, above content
- Each tab = one account section (Profile, Password, 2FA, External Logins)
- Active tab has primary color underline indicator
- Overflows horizontally with hidden scrollbar (`overflow-x-auto scrollbar-hide`)
- Uses `whitespace-nowrap` to prevent wrapping
- The glass-card wrapper around nav is removed on mobile (tabs are bare)
- Content card remains full-width
- **Breakpoint change**: ManageLayout currently uses `sm:` (640px) for the sidebar/content split. This changes to `md:` (768px) to match the AppLayout threshold. Between 640-768px (small tablets), users will now see horizontal tabs instead of the sidebar — this is intentional for consistency.

**Desktop (>= 768px):**
- No changes. Existing sticky sidebar preserved.

**Files to modify:**
- `framework/SimpleModule.Blazor/Components/Layout/ManageLayout.razor` — responsive classes for mobile tabs vs desktop sidebar
- `framework/SimpleModule.Blazor/Components/ManageNav.razor` — add responsive classes for horizontal variant (`flex-row overflow-x-auto` on mobile, `flex-col` on desktop)
- `packages/SimpleModule.Theme.Default/theme.css` — scrollbar-hide utility, tab active underline styles

### 4. Shared Conventions

- All breakpoints use Tailwind's `md:` (768px) as the mobile/desktop threshold, matching the existing `.app-sidebar` media query
- Touch targets minimum 44px height on mobile (WCAG 2.5.5)
- Animations use CSS transitions (0.25s ease) for consistency with existing sidebar animation
- **Reduced motion**: add `@media (prefers-reduced-motion: reduce)` rule that disables transitions on sidebar, backdrop, and overlay
- All JS is vanilla — no framework dependencies — matching existing `shell.js` pattern
- State managed via CSS classes toggled by JS, matching existing patterns
- **ARIA**: hamburger buttons get `aria-expanded`, overlays get `aria-hidden`, PublicLayout overlay gets `role="dialog"` + `aria-modal="true"`
- **Hamburger icon**: consistent 3-line SVG icon used in both AppLayout and PublicLayout (inline SVG, matching existing icon pattern)

### 5. Files Changed (Summary)

| File | Change |
|------|--------|
| `AppLayout.razor` | Add mobile header, backdrop element, update inline JS |
| `PublicLayout.razor` | Add hamburger button, full-screen overlay with accordion nav, dual markup |
| `ManageLayout.razor` | Add horizontal tab bar for mobile, keep sidebar for desktop |
| `ManageNav.razor` | Add responsive classes for horizontal/vertical variants |
| `shell.js` | Swipe gesture, mobile toggle, scroll lock, escape key, matchMedia cleanup |
| `theme.css` | Hide collapse toggle on mobile, scrollbar-hide, tab styles, prefers-reduced-motion |

### 6. What Is NOT Changing

- React page components — all layout changes are Blazor SSR
- Desktop behavior for any layout
- Sidebar content (nav items, admin section, footer)
- Theme colors, typography, or design system tokens
- Any module pages or their responsive patterns (already handled in prior commit)
52 changes: 48 additions & 4 deletions framework/SimpleModule.Blazor/Components/Layout/AppLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
@inject IMenuRegistry MenuRegistry

<div class="app-layout">
<!-- Mobile header -->
<div class="app-mobile-header">
<button id="app-mobile-hamburger" onclick="openMobileSidebar()" aria-expanded="false" aria-label="Open navigation" class="p-1 -ml-1 text-text-muted hover:text-text">
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<a href="/" class="flex items-center gap-2 no-underline font-bold text-text" style="font-family:'Sora',sans-serif;">
<span class="w-7 h-7 rounded-lg flex items-center justify-center text-white text-xs font-bold" style="background:linear-gradient(135deg,var(--color-primary),var(--color-accent));">S</span>
<span class="text-sm">SimpleModule</span>
</a>
</div>

<aside class="app-sidebar" id="app-sidebar">
<div class="flex flex-col h-full">
<!-- Logo -->
Expand All @@ -14,10 +25,10 @@
</div>

<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-3 py-4 space-y-1">
<nav class="flex-1 overflow-y-auto px-3 py-4 space-y-1" id="app-sidebar-nav">
@foreach (var item in SidebarItems)
{
<a href="@item.Url" class="@SidebarLinkClass(item.Url)">
<a href="@item.Url" class="@SidebarLinkClass(item.Url)" data-nav-url="@item.Url" onclick="if(window.closeMobileSidebar)closeMobileSidebar()">
<span class="sidebar-icon">@((MarkupString)item.Icon)</span>
<span class="sidebar-label">@item.Label</span>
</a>
Expand All @@ -44,7 +55,7 @@
<div id="sg-@menuGroup.GroupId" class="space-y-0.5 mt-0.5">
@foreach (var item in menuGroup.Items)
{
<a href="@item.Url" class="@SidebarLinkClass(item.Url)">
<a href="@item.Url" class="@SidebarLinkClass(item.Url)" data-nav-url="@item.Url" onclick="if(window.closeMobileSidebar)closeMobileSidebar()">
<span class="sidebar-icon">@((MarkupString)item.Icon)</span>
<span class="sidebar-label">@item.Label</span>
</a>
Expand All @@ -56,7 +67,7 @@
{
@foreach (var item in menuGroup.Items)
{
<a href="@item.Url" class="@SidebarLinkClass(item.Url)">
<a href="@item.Url" class="@SidebarLinkClass(item.Url)" data-nav-url="@item.Url" onclick="if(window.closeMobileSidebar)closeMobileSidebar()">
<span class="sidebar-icon">@((MarkupString)item.Icon)</span>
<span class="sidebar-label">@item.Label</span>
</a>
Expand All @@ -79,6 +90,9 @@
</div>
</aside>

<!-- Mobile backdrop -->
<div class="app-sidebar-backdrop" id="app-sidebar-backdrop" aria-hidden="true"></div>

<!-- Sidebar collapse toggle -->
<button class="app-sidebar-toggle" id="app-sidebar-toggle" onclick="toggleSidebar()" title="Toggle sidebar">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/></svg>
Expand Down Expand Up @@ -106,6 +120,36 @@
};
})();

// Update active nav link after enhanced navigation
(function() {
var activeClass = 'flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium text-primary bg-primary-subtle no-underline transition-all duration-150';
var inactiveClass = 'flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-text-secondary no-underline hover:bg-surface-raised hover:text-text transition-all duration-150';

function updateActiveLink() {
var path = window.location.pathname;
var links = document.querySelectorAll('#app-sidebar [data-nav-url]');
for (var i = 0; i < links.length; i++) {
var url = links[i].getAttribute('data-nav-url');
var isActive = url === '/'
? path === '/'
: path.toLowerCase().indexOf(url.toLowerCase()) === 0;
links[i].className = isActive ? activeClass : inactiveClass;
}
}

// Detect Inertia navigations by observing URL changes
var lastUrl = window.location.pathname;
var observer = new MutationObserver(function() {
if (window.location.pathname !== lastUrl) {
lastUrl = window.location.pathname;
updateActiveLink();
}
});
observer.observe(document.getElementById('app') || document.body, { childList: true, subtree: true });
// Also run on popstate (browser back/forward)
window.addEventListener('popstate', function() { setTimeout(updateActiveLink, 0); });
})();

// Admin nav collapse
(function() {
var items = document.getElementById('admin-nav-items');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
else
{
<a href="/Identity/Account/Login" class="btn-ghost btn-sm no-underline">Log in</a>
<a href="/Identity/Account/Register" class="btn-primary btn-sm no-underline hidden sm:inline-flex">Sign up</a>
<a href="/Identity/Account/Register" class="btn-primary btn-sm no-underline hidden md:inline-flex">Sign up</a>
}
</div>
</div>
Expand Down
18 changes: 12 additions & 6 deletions framework/SimpleModule.Blazor/Components/Layout/ManageLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,30 @@
@inject NavigationManager Navigation
@inject Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider SchemeProvider

<div class="mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8 space-y-6">
<div class="mx-auto w-full max-w-7xl px-4 md:px-6 lg:px-8 space-y-6">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-extrabold tracking-tight" style="font-family:'Sora',sans-serif;">
<h1 class="text-xl md:text-2xl font-extrabold tracking-tight" style="font-family:'Sora',sans-serif;">
<span class="gradient-text">Account Settings</span>
</h1>
<p class="text-text-muted text-sm mt-1">Manage your profile, security, and preferences</p>
</div>
</div>

<div class="flex flex-col sm:flex-row gap-6">
<aside class="sm:w-56 shrink-0">
<div class="glass-card p-3 sm:sticky sm:top-20">
<!-- Mobile: horizontal tab bar -->
<div class="md:hidden">
<ManageNav ActivePage="@GetActivePage()" HasExternalLogins="@_hasExternalLogins" Horizontal="true" />
</div>

<div class="flex flex-col md:flex-row gap-6">
<!-- Desktop: vertical sidebar nav -->
<aside class="hidden md:block md:w-56 shrink-0">
<div class="glass-card p-3 sticky top-20">
<ManageNav ActivePage="@GetActivePage()" HasExternalLogins="@_hasExternalLogins" />
</div>
</aside>
<div class="flex-1 min-w-0">
<div class="glass-card p-6 sm:p-8">
<div class="glass-card p-4 md:p-8">
@Body
</div>
</div>
Expand Down
Loading
Loading