Mobile foundation (Phase 1) + harden opportunity stage validation
Phase 1 mobile foundation (additive, no desktop change): :root mobile vars, a 4-tab bottom nav bar + mobile account/logout popover wired into App, a bottom-sheet CSS primitive, and .mobile-only/.desktop-only utilities -- all display:none >=768px. The <BottomSheet> React component + useIsMobile() + the per-surface 15px type bump are deferred to Phase 2 (first use); light theme to Phase 6. Review hardening (fresh-eyes pass on the Phase 0+1 diff): validate stage in handle_create_opportunity + handle_update_opportunity against PIPELINE_STAGES -- the narrower 4-stage enum makes a stale-client write of a legacy value invisible to the report ORDER BY CASEs and unsettable from the UI. Use the canonical pipelineStageLabel in the opportunity detail select; document the intentional graveyard omission in the existing_investor / staleness helpers. Tests: stage-validation regression in test_grid_pipeline_link.py + empty source_row_id guard in test_pipeline_stages_v2.py; 36/36 green, render-smoke green.
This commit is contained in:
+172
-2
@@ -36,6 +36,20 @@
|
||||
--accent: #3b82c4;
|
||||
--accent-strong: #2f6ea9;
|
||||
--accent-soft: #3b82c422;
|
||||
--text-subtle: #70859b;
|
||||
--border-strong: #35506a;
|
||||
/* Mobile-first foundation (DESIGN §3/§8, tokens `mobile` group). Sizing/radii used
|
||||
by the bottom tab bar + bottom-sheet primitive; per-surface type bumps land with
|
||||
each surface (Phases 2–5), not as a global body rule (components set own px). */
|
||||
--mobile-tab-bar-h: 56px;
|
||||
--mobile-touch-target: 44px;
|
||||
--mobile-input-h: 46px;
|
||||
--mobile-sheet-radius: 20px;
|
||||
--mobile-screen-pad-x: 16px;
|
||||
--mobile-card-gap: 10px;
|
||||
--mobile-font-body: 15px;
|
||||
--mobile-font-sheet-title: 18px;
|
||||
--mobile-font-tab-label: 10px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@@ -1882,6 +1896,125 @@
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── MOBILE-FIRST FOUNDATION (Phase 1) ─────────────────────────────────────
|
||||
Responsive chrome lives in CSS, not inline styles (DESIGN §8/§9). The bottom
|
||||
tab bar + bottom-sheet primitive are display:none on desktop and switched on
|
||||
under the <768px media query at the end of this block, so desktop is unchanged.
|
||||
Light theme is Phase 6; per-surface 15px type bumps land with each surface. */
|
||||
|
||||
/* Bottom tab bar — the four mobile surfaces (Grid/Pipeline/Reminders/Contacts). */
|
||||
.bottom-tab-bar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
z-index: 200;
|
||||
height: calc(var(--mobile-tab-bar-h) + env(safe-area-inset-bottom, 0px));
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
background: rgba(17, 26, 39, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.bottom-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-subtle);
|
||||
cursor: pointer;
|
||||
min-height: var(--mobile-tab-bar-h);
|
||||
padding: 6px 0;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.bottom-tab.active { color: var(--accent); }
|
||||
.bottom-tab-icon { font-size: 20px; line-height: 1; }
|
||||
.bottom-tab-label {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: var(--mobile-font-tab-label);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Bottom-sheet primitive — replaces the centered modal + right slide-over on mobile.
|
||||
Rendered by the <BottomSheet> component; `.open` is toggled to animate in. */
|
||||
.sheet-scrim {
|
||||
position: fixed; inset: 0; z-index: 300;
|
||||
background: rgba(4, 9, 16, 0.55);
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.24s ease;
|
||||
}
|
||||
.sheet-scrim.open { opacity: 1; pointer-events: auto; }
|
||||
.bottom-sheet {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 301;
|
||||
max-height: 88vh;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid var(--border-strong);
|
||||
border-radius: var(--mobile-sheet-radius) var(--mobile-sheet-radius) 0 0;
|
||||
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.45);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.bottom-sheet.open { transform: translateY(0); }
|
||||
.sheet-handle {
|
||||
width: 38px; height: 4px; border-radius: 2px;
|
||||
background: #3a4a5e;
|
||||
margin: 10px auto 6px;
|
||||
flex: none;
|
||||
}
|
||||
.sheet-title {
|
||||
font-size: var(--mobile-font-sheet-title);
|
||||
font-weight: 600;
|
||||
padding: 4px 16px 12px;
|
||||
}
|
||||
.sheet-body { overflow-y: auto; padding: 0 16px 16px; }
|
||||
|
||||
/* Mobile account control — the only non-tab navigation on mobile (DESIGN §4).
|
||||
Renders inside .mobile-only, so these are inert on desktop. */
|
||||
.account-btn {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-panel-elevated);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600; font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.account-scrim { position: fixed; inset: 0; z-index: 240; }
|
||||
.account-popover {
|
||||
position: absolute; right: 0; top: 44px; z-index: 250;
|
||||
min-width: 170px;
|
||||
background: var(--bg-panel-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
padding: 10px;
|
||||
}
|
||||
.account-popover-name { font-size: 13px; color: var(--text-secondary); padding: 2px 6px 8px; }
|
||||
.account-popover-logout {
|
||||
width: 100%; text-align: left;
|
||||
background: #1b2837; color: var(--text-primary);
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 9px 10px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.account-popover-logout:hover { background: var(--bg-hover); }
|
||||
|
||||
/* Visibility utilities — base = desktop; flipped under the breakpoint. */
|
||||
.mobile-only { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bottom-tab-bar { display: flex; }
|
||||
.mobile-only { display: block; }
|
||||
.desktop-only { display: none !important; }
|
||||
/* keep content clear of the fixed bottom bar */
|
||||
.content { padding-bottom: calc(var(--mobile-tab-bar-h) + env(safe-area-inset-bottom, 0px) + 16px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -4387,7 +4520,7 @@
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{stages.map(s => (
|
||||
<option key={s} value={s}>{s.replace(/_/g, ' ')}</option>
|
||||
<option key={s} value={s}>{pipelineStageLabel(s)}</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
@@ -11198,6 +11331,7 @@
|
||||
const App = () => {
|
||||
const { token, user, logout } = useAuth();
|
||||
const [page, setPage] = useState('fundraising-grid');
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false); // mobile top-bar account popover
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [sidebarHidden, setSidebarHidden] = useState(false);
|
||||
const [gridViews, setGridViews] = useState(loadGridViews());
|
||||
@@ -11466,9 +11600,23 @@
|
||||
{page === 'settings' && 'Settings'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<div className="user-info desktop-only">
|
||||
{user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''}
|
||||
</div>
|
||||
<div className="mobile-only" style={{ position: 'relative' }}>
|
||||
<button className="account-btn" onClick={() => setAccountMenuOpen((o) => !o)} aria-label="Account menu">
|
||||
{(user?.full_name || user?.username || '?').slice(0, 1).toUpperCase()}
|
||||
</button>
|
||||
{accountMenuOpen && (
|
||||
<>
|
||||
<div className="account-scrim" onClick={() => setAccountMenuOpen(false)} />
|
||||
<div className="account-popover">
|
||||
<div className="account-popover-name">{user?.full_name || user?.username}</div>
|
||||
<button className="account-popover-logout" onClick={handleLogout}>Logout</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
@@ -11533,6 +11681,28 @@
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile primary navigation — the four mobile surfaces. CSS-hidden on
|
||||
desktop (.bottom-tab-bar display:none until <768px); the sidebar is the
|
||||
desktop nav. Other destinations are intentionally absent on mobile. */}
|
||||
<nav className="bottom-tab-bar" aria-label="Primary">
|
||||
{[
|
||||
{ id: 'fundraising-grid', icon: '▦', label: 'Grid' },
|
||||
{ id: 'pipeline', icon: '↗', label: 'Pipeline' },
|
||||
{ id: 'reminders', icon: '⏰', label: 'Reminders' },
|
||||
{ id: 'contacts', icon: '◎', label: 'Contacts' },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`bottom-tab ${page === t.id ? 'active' : ''}`}
|
||||
onClick={() => setPage(t.id)}
|
||||
aria-current={page === t.id ? 'page' : undefined}
|
||||
>
|
||||
<span className="bottom-tab-icon">{t.icon}</span>
|
||||
<span className="bottom-tab-label">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user