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:
Keysat
2026-06-19 13:15:53 -05:00
parent e46dd36517
commit 634fc4260f
6 changed files with 221 additions and 15 deletions
+172 -2
View File
@@ -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 25), 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>
);
};