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
+4 -4
View File
@@ -107,14 +107,14 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
## Current state ## Current state
_Phase 0 + Phase 1 built; **box + repo live at v0.1.0:94** (`main` ahead by docs/design-only commits since). **The fundraising grid + email capture is the canonical system of record.** Active threads: **mobile-first redesign** (design DONE → scoped + **Phase 0 data layer BUILT 2026-06-19**, deploy pending; mobile foundation next) and **W2 NL query** (live; web "Ask" box outstanding). History: git log + `start9/0.4/startos/versions/`; backlog/debt: `ROADMAP.md` / `EVALUATION.md`._ _Phase 0 + Phase 1 built; **box + repo live at v0.1.0:94** (`main` ahead by docs/design-only commits since). **The fundraising grid + email capture is the canonical system of record.** Active threads: **mobile-first redesign** (design DONE → scoped + **Phase 0 data layer + Phase 1 mobile foundation BUILT 2026-06-19**, deploy pending; Contacts surface next) and **W2 NL query** (live; web "Ask" box outstanding). History: git log + `start9/0.4/startos/versions/`; backlog/debt: `ROADMAP.md` / `EVALUATION.md`._
- **Mobile-first redesign — design phase COMPLETE; implementation not started.** This session ran the `/design` round-trip Phase C/D: distilled the Claude Design cloud output ("Venture-CRM mobile redesign") into the contract — `DESIGN.md` §8 (responsive) + §4 (mobile component states) + §3 (15px scale), tokens `mobile` group + `color.light`, provenance + per-surface interaction reference in `design/_imports/2026-06-19/`. **Light theme adopted as a planned, toggle-gated feature** (dark default). Comps are Claude Design **runtime prototypes** — re-author each surface in React against the real API, not drop-in. Process learnings pushed to `standards/guides/design.md`. - **Mobile-first redesign — design phase COMPLETE; implementation not started.** This session ran the `/design` round-trip Phase C/D: distilled the Claude Design cloud output ("Venture-CRM mobile redesign") into the contract — `DESIGN.md` §8 (responsive) + §4 (mobile component states) + §3 (15px scale), tokens `mobile` group + `color.light`, provenance + per-surface interaction reference in `design/_imports/2026-06-19/`. **Light theme adopted as a planned, toggle-gated feature** (dark default). Comps are Claude Design **runtime prototypes** — re-author each surface in React against the real API, not drop-in. Process learnings pushed to `standards/guides/design.md`.
- **Mobile implementation — SCOPED 2026-06-19 (plan in `ROADMAP.md` "Mobile-first implementation").** Key finding: the inline-style→CSS "blocker" is **~114 inline styles across the 4 surfaces + shell** (Grid 70 / Reminders 18 / Contacts 17 / Pipeline 7 / shell 2), **not ~1,300** — the app is already majority class-based (1,861-line `<style>`, 1,088 `className`s, 4 media queries). So it's **divisible per-surface, no upfront sweep**, and splits into two axes: *responsive* (layout→classes, gates mobile) vs *theming* (inline-hex→`var()`, 183 literals, gates light theme). Sequence: **Phase 0** pipeline-stages/flags data layer (standalone) → **Phase 1** shared foundation (tokens/`:root` + shell + bottom-tabs + sheet primitive) → **Phase 2** Contacts (validator) → **Phase 3** Grid (crux) → **Phase 4** Pipeline → **Phase 5** Reminders → **Phase 6** light theme. **Phase 0 is BUILT + tested (2026-06-19, deploy pending)** enum→4 stages + migration `0007` + `existing_investor`/`staleness` injection; the *visible* existing-investor star + staleness recency column + Stale view deferred to Phase 3 (data injected + test-locked now). See ROADMAP for the change set. - **Mobile implementation — SCOPED 2026-06-19 (plan in `ROADMAP.md` "Mobile-first implementation").** Key finding: the inline-style→CSS "blocker" is **~114 inline styles across the 4 surfaces + shell** (Grid 70 / Reminders 18 / Contacts 17 / Pipeline 7 / shell 2), **not ~1,300** — the app is already majority class-based (1,861-line `<style>`, 1,088 `className`s, 4 media queries). So it's **divisible per-surface, no upfront sweep**, and splits into two axes: *responsive* (layout→classes, gates mobile) vs *theming* (inline-hex→`var()`, 183 literals, gates light theme). Sequence: **Phase 0** pipeline-stages/flags data layer (standalone) → **Phase 1** shared foundation (tokens/`:root` + shell + bottom-tabs + sheet primitive) → **Phase 2** Contacts (validator) → **Phase 3** Grid (crux) → **Phase 4** Pipeline → **Phase 5** Reminders → **Phase 6** light theme. **Phase 0 + Phase 1 are BUILT (2026-06-19, deploy pending).** Phase 0: enum→4 stages + migration `0007` + `existing_investor`/`staleness` injection (visible star/staleness column + Stale view deferred to Phase 3). Phase 1: `:root` mobile vars + `.bottom-tab-bar` (4 tabs wired in `App`) + mobile account popover + `.bottom-sheet` CSS primitive + `.mobile-only`/`.desktop-only` utils — all `display:none` on desktop (zero desktop change); the `<BottomSheet>` React component + `useIsMobile()` + per-surface 15px bump deferred to Phase 2 (first use). See ROADMAP for both change sets.
- **Built, deploy pending:** **drag-reorder grid views** (frontend-only; `moveViewBefore` in `index.html`; persists via autosave → `views_json`; render-smoke green, browser-interaction untested). - **Built, deploy pending:** **drag-reorder grid views** (frontend-only; `moveViewBefore` in `index.html`; persists via autosave → `views_json`; render-smoke green, browser-interaction untested).
- **W2 — NL query (read-only): LIVE** (v93; matched-only fix v94). Local-Qwen translate → curated intents + slot validator (no generic SQL), `POST /api/query/nl`, audited; Matrix Q&A + intake `?`/`@bot` live. Remaining: **in-room human smoke** + **step-4 web "Ask" box**. Guides: `docs/guides/nl-query.md` + matrix-intake. - **W2 — NL query (read-only): LIVE** (v93; matched-only fix v94). Local-Qwen translate → curated intents + slot validator (no generic SQL), `POST /api/query/nl`, audited; Matrix Q&A + intake `?`/`@bot` live. Remaining: **in-room human smoke** + **step-4 web "Ask" box**. Guides: `docs/guides/nl-query.md` + matrix-intake.
- **W1 — reminders: LIVE (v93).** Grid-tied tickler (migration `0006`, `/api/reminders`, derived `reminder_status`, `last_activity_at` rollup). Deferred **W1b** = nurture-gap auto-suggested reminders (staleness nudge → Engaged/Diligence). - **W1 — reminders: LIVE (v93).** Grid-tied tickler (migration `0006`, `/api/reminders`, derived `reminder_status`, `last_activity_at` rollup). Deferred **W1b** = nurture-gap auto-suggested reminders (staleness nudge → Engaged/Diligence).
- **Done & live:** email-proposal Matrix review + `bot` role (v91); grid-driven Pipeline (v88); Matrix intake bot; Gmail capture (DWD) + propose→approve + daily digest; Thesis Workshop + Architect (Claude, dual-approval); outreach drafts. All draft-only. - **Done & live:** email-proposal Matrix review + `bot` role (v91); grid-driven Pipeline (v88); Matrix intake bot; Gmail capture (DWD) + propose→approve + daily digest; Thesis Workshop + Architect (Claude, dual-approval); outreach drafts. All draft-only.
- **Tests:** **36/36 backend green** (`python3 backend/run_tests.py`; +`test_pipeline_stages_v2.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean. (Phase 0 code shipped this session.) - **Tests:** **36/36 backend green** (`python3 backend/run_tests.py`; +`test_pipeline_stages_v2.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean. (Phase 0 code shipped this session.)
- **Next (priority order):** 1) **deploy Phase 0** (s9pk build + install — pipeline-stages/flags data layer; bundle view-reorder, which is also deploy-pending; **authorize first**); 2) **Phase 1mobile foundation** (tokens/`:root` + viewport-gated shell + 4-tab bottom bar + bottom-sheet primitive); 3) mobile surfaces **Contacts → Grid → Pipeline → Reminders** (writes via one-row `log-communication` + pipeline link→stage, never whole-grid PUT; the deferred existing-investor star + staleness recency column + Stale view co-land here); 4) **Phase 6 light theme** (inline-hex→`var()` + `[data-theme]` toggle); 5) **W2 step-4** web Ask box + in-room smoke; 6) **W3** bot grid-mutations behind Matrix gate; 7) **W1b** nurture-gap reminders (target Engaged/Diligence); then P2 debt (reports comms-aggregate soft-delete sweep, `?limit=abc` crash, auth regression test, oversized icon). - **Next (priority order):** 1) **Phase 2 — Contacts surface** (read-only AZ list + segmented tabs + search → full-screen detail; lands the `<BottomSheet>` component + `useIsMobile()` + 15px bump — the list→detail→sheet validator before the Grid); 2) **Phase 3Grid** (crux; card list + view-picker sheet + per-field edit sheets + existing-investor star/staleness rendering; writes via one-row `log-communication` + pipeline link→stage, never whole-grid PUT); 3) **Phase 4 Pipeline → Phase 5 Reminders**; 4) **Phase 6 light theme** (inline-hex→`var()` + `[data-theme]` toggle); 5) **deploy** the accumulated Phase 0 + Phase 1 (+ view-reorder) in one s9pk build (**authorize first**); 6) **W2 step-4** web Ask box + in-room smoke; 7) **W3** bot grid-mutations behind Matrix gate; 8) **W1b** nurture-gap reminders (target Engaged/Diligence); then P2 debt (reports comms-aggregate soft-delete sweep, `?limit=abc` crash, auth regression test, oversized icon).
- **Open / risks:** **Phase 0 built but not yet deployed** — the `0007` enum migration is a no-op on the live DB (0 opps today, so near-zero remap risk), and the derived `existing_investor`/`staleness` signals are injected + test-locked but not yet *rendered* on desktop (that lands in Phase 3); the mobile UI itself is still unbuilt (Grid is the heavy surface at ~70 inline styles + the two-call stage write path); W2 translation only **happy-path-validated**; **Claude/Architect path still unverified live on the box**; v2.0 reserve-asset spine approved but **not canonical** (needs dual sign-off); doc drift — `crm-overview.md` + `EVALUATION.md` still call `lp_profiles` live. - **Open / risks:** **Phase 0 built but not yet deployed** — the `0007` enum migration is a no-op on the live DB (0 opps today, so near-zero remap risk), and the derived `existing_investor`/`staleness` signals are injected + test-locked but not yet *rendered* on desktop (that lands in Phase 3). **Phase 1 mobile shell/nav is built but browser-untested** (render-smoke only — the bottom tab bar shows authenticated + <768px; verify on a real phone, like view-reorder); the four mobile *surfaces* are still unbuilt (Grid is the heavy one at ~70 inline styles + the two-call stage write path). W2 translation only **happy-path-validated**; **Claude/Architect path still unverified live on the box**; v2.0 reserve-asset spine approved but **not canonical** (needs dual sign-off); doc drift — `crm-overview.md` + `EVALUATION.md` still call `lp_profiles` live.
+15 -6
View File
@@ -323,15 +323,24 @@ migration into each surface's build, behind one shared foundation step. No upfro
staleness-colored recency column + the seeded "Stale" saved view — the data is injected and staleness-colored recency column + the seeded "Stale" saved view — the data is injected and
test-locked now, so Phase 3 is pure frontend. W1b nudge specialization is a separate fast-follow. test-locked now, so Phase 3 is pure frontend. W1b nudge specialization is a separate fast-follow.
**Deploy:** needs an s9pk build + install (**authorize first**). **Deploy:** needs an s9pk build + install (**authorize first**).
- **Phase 1 — Shared mobile foundation (the only do-once part of the migration).** Extend `:root` with - **Phase 1 — Shared mobile foundation — BUILT 2026-06-19 (deploy pending).** Shipped: `:root` mobile
the missing tokens (semantic colors + the `mobile` token group + a `[data-theme="light"]` block); add vars (`--mobile-tab-bar-h`/`--mobile-touch-target`/`--mobile-input-h`/`--mobile-sheet-radius`/screen-pad +
CSS for the bottom-tab-bar, the bottom-sheet primitive, `env(safe-area-inset-bottom)`, and the 13→15px fonts + `--text-subtle`/`--border-strong`); CSS for the safe-area-aware **`.bottom-tab-bar`**, the
type bump; build the viewport-gated shell in `App` (bottom 4-tab bar <768px, hide sidebar, top-bar **`.bottom-sheet`/`.sheet-scrim`/`.sheet-handle`** primitive (styling), and `.mobile-only`/`.desktop-only`
account/logout control). Touches ~2 inline styles. utilities — all `display:none` on desktop so **zero desktop change**; the **4-tab bottom bar**
(Grid·Pipeline·Reminders·Contacts → `setPage`) + a **mobile account/logout popover** wired into `App`
(sidebar already CSS-hidden <768px). Render-smoke green. **Deliberately deferred:** (a) the
**`<BottomSheet>` React component + `useIsMobile()` hook** → Phase 2, designed against their first real
consumer (no dead code); (b) the **13→15px type bump is per-surface**, not a global body rule — `body`
has no base font-size, so it lands as each surface is re-authored (Phases 25); (c) the
`[data-theme="light"]` block → Phase 6 (dead without the toggle). Browser-interaction (the bar on a real
phone) untested, like view-reorder.
- **Phase 2 — Contacts (pattern-validator spike, BEFORE the Grid).** ~17 inline styles; read-only AZ - **Phase 2 — Contacts (pattern-validator spike, BEFORE the Grid).** ~17 inline styles; read-only AZ
list + segmented tabs + search → full-screen read-only detail. Proves the list→detail→sheet pattern list + segmented tabs + search → full-screen read-only detail. Proves the list→detail→sheet pattern
and the per-surface migration mechanics on the lowest-risk surface before the crux. *(Reorders the and the per-surface migration mechanics on the lowest-risk surface before the crux. *(Reorders the
earlier "Grid first" draft — de-risk the pattern cheaply, then attack the Grid.)* earlier "Grid first" draft — de-risk the pattern cheaply, then attack the Grid.)* Also lands the
**`<BottomSheet>` React component + `useIsMobile()` hook** (deferred from Phase 1, first consumed here,
built against the Phase 1 `.bottom-sheet` CSS) and this surface's **15px type bump**.
- **Phase 3 — Fundraising Grid (the crux).** ~70 inline styles → classes. Card list + bottom-sheet view - **Phase 3 — Fundraising Grid (the crux).** ~70 inline styles → classes. Card list + bottom-sheet view
picker + search; full-screen detail with per-field bottom-sheet edits (name, contact pills, stage, picker + search; full-screen detail with per-field bottom-sheet edits (name, contact pills, stage,
reminder, log note) + the `+`-create flow with client-side dedup typeahead. **Writes per `BRIEF.md` reminder, log note) + the `+`-create flow with client-side dedup typeahead. **Writes per `BRIEF.md`
+15 -2
View File
@@ -1807,7 +1807,10 @@ def existing_investor_by_source_row(conn):
"""Return the set of grid source_row_ids whose investor has any committed capital """Return the set of grid source_row_ids whose investor has any committed capital
(fundraising_investors.total_invested > 0) the auto-derived "Existing Investor" flag (fundraising_investors.total_invested > 0) the auto-derived "Existing Investor" flag
(locked spec 2026-06-19). Injected read-only on grid read like pipeline_stage; never a (locked spec 2026-06-19). Injected read-only on grid read like pipeline_stage; never a
maintained column. Orthogonal to stage: a re-solicited LP shows the star AND a live stage.""" maintained column. Orthogonal to stage: a re-solicited LP shows the star AND a live stage.
Deliberately NOT filtered by `graveyard` (unlike the dashboard total-invested aggregate):
committed capital makes an investor "existing" regardless of disposition; graveyard rows are
muted/filtered by the view, not by this signal. Same intentional omission in staleness below."""
out = set() out = set()
for r in conn.execute( for r in conn.execute(
"SELECT source_row_id FROM fundraising_investors WHERE total_invested > 0" "SELECT source_row_id FROM fundraising_investors WHERE total_invested > 0"
@@ -2968,6 +2971,12 @@ class CRMHandler(BaseHTTPRequestHandler):
def handle_create_opportunity(self, user, body): def handle_create_opportunity(self, user, body):
if not body.get('name') or not body.get('contact_id'): if not body.get('name') or not body.get('contact_id'):
return self.send_error_json("name and contact_id are required") return self.send_error_json("name and contact_id are required")
# Validate stage (mirrors handle_update_stage). Matters more since the 4-stage migration:
# a stale cached client could otherwise write a legacy value that's invisible to the
# report ORDER BY CASEs and unsettable from the UI.
stage = body.get('stage', 'lead')
if stage not in PIPELINE_STAGES:
return self.send_error_json(f"Invalid stage. Must be one of: {', '.join(PIPELINE_STAGES)}")
opp_id = generate_id() opp_id = generate_id()
conn = get_db() conn = get_db()
@@ -2988,7 +2997,7 @@ class CRMHandler(BaseHTTPRequestHandler):
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
opp_id, body['name'], body['contact_id'], org_id, opp_id, body['name'], body['contact_id'], org_id,
body.get('stage', 'lead'), stage,
body.get('commitment_amount', 0), body.get('expected_amount', 0), body.get('commitment_amount', 0), body.get('expected_amount', 0),
body.get('probability', 10), body.get('expected_close_date'), body.get('probability', 10), body.get('expected_close_date'),
body.get('fund_name'), body.get('description'), body.get('next_step'), body.get('fund_name'), body.get('description'), body.get('next_step'),
@@ -3014,6 +3023,10 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.close() conn.close()
return self.send_error_json("Opportunity not found", 404) return self.send_error_json("Opportunity not found", 404)
if 'stage' in body and body['stage'] not in PIPELINE_STAGES:
conn.close()
return self.send_error_json(f"Invalid stage. Must be one of: {', '.join(PIPELINE_STAGES)}")
updatable = ['name', 'contact_id', 'organization_id', 'stage', 'commitment_amount', updatable = ['name', 'contact_id', 'organization_id', 'stage', 'commitment_amount',
'expected_amount', 'probability', 'expected_close_date', 'fund_name', 'expected_amount', 'probability', 'expected_close_date', 'fund_name',
'description', 'next_step', 'owner_id', 'priority', 'lost_reason'] 'description', 'next_step', 'owner_id', 'priority', 'lost_reason']
+10
View File
@@ -149,6 +149,16 @@ def main():
f"funnel fields preserved, not reseeded (got stage={opp2.get('stage')}, amt={opp2.get('expected_amount')})") f"funnel fields preserved, not reseeded (got stage={opp2.get('stage')}, amt={opp2.get('expected_amount')})")
check(_opp_count_live(fr_id) == 1, "still exactly one live opp (no duplicate)") check(_opp_count_live(fr_id) == 1, "still exactly one live opp (no duplicate)")
# ── stage validation: legacy/invalid values rejected (4-stage enum guard) ──
# The stage check precedes the contact lookup in handle_create_opportunity, so a fake
# contact_id still surfaces the stage error first.
print("\n[validation: legacy stage values rejected by stage + create endpoints]")
st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "outreach"})
check(st >= 400, f"PATCH legacy stage 'outreach' rejected (got {st})")
st, _ = _req(port, "POST", "/api/opportunities", token,
{"name": "X", "contact_id": "x", "stage": "due_diligence"})
check(st >= 400, f"POST opportunity with legacy stage 'due_diligence' rejected (got {st})")
# ── read-injection: GET state shows pipeline flag + stage, derived live ── # ── read-injection: GET state shows pipeline flag + stage, derived live ──
print("\n[read-injection: GET /state exposes read-only pipeline + pipeline_stage]") print("\n[read-injection: GET /state exposes read-only pipeline + pipeline_stage]")
st, d = _req(port, "GET", "/api/fundraising/state", token) st, d = _req(port, "GET", "/api/fundraising/state", token)
+5 -1
View File
@@ -106,10 +106,14 @@ def test_derivations(conn):
_investor(conn, "rowB59", 0, contact_id="c_b59", comm_days_ago=59) # -> aging _investor(conn, "rowB59", 0, contact_id="c_b59", comm_days_ago=59) # -> aging
_investor(conn, "rowB30", 0, contact_id="c_b30", comm_days_ago=30) # boundary -> aging _investor(conn, "rowB30", 0, contact_id="c_b30", comm_days_ago=30) # boundary -> aging
_investor(conn, "rowB29", 0, contact_id="c_b29", comm_days_ago=29) # -> fresh _investor(conn, "rowB29", 0, contact_id="c_b29", comm_days_ago=29) # -> fresh
# Empty source_row_id with committed capital — must be EXCLUDED by the `if not srid` guard
# (would otherwise key the injection under '' and clobber a real row).
_investor(conn, "", 9_999, contact_id="c_empty", comm_days_ago=100)
conn.commit() conn.commit()
existing = server.existing_investor_by_source_row(conn) existing = server.existing_investor_by_source_row(conn)
check(existing == {"rowExist"}, f"existing_investor = total_invested>0 only (got {sorted(existing)})") check(existing == {"rowExist"},
f"existing_investor = total_invested>0 with a non-empty source_row_id only (got {sorted(existing)})")
st = server.staleness_by_source_row(conn) st = server.staleness_by_source_row(conn)
level = lambda srid: st.get(srid, (None, "MISSING"))[1] level = lambda srid: st.get(srid, (None, "MISSING"))[1]
+172 -2
View File
@@ -36,6 +36,20 @@
--accent: #3b82c4; --accent: #3b82c4;
--accent-strong: #2f6ea9; --accent-strong: #2f6ea9;
--accent-soft: #3b82c422; --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; } * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -1882,6 +1896,125 @@
scroll-behavior: auto !important; 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> </style>
</head> </head>
<body> <body>
@@ -4387,7 +4520,7 @@
style={{ width: '100%' }} style={{ width: '100%' }}
> >
{stages.map(s => ( {stages.map(s => (
<option key={s} value={s}>{s.replace(/_/g, ' ')}</option> <option key={s} value={s}>{pipelineStageLabel(s)}</option>
))} ))}
</select> </select>
</span> </span>
@@ -11198,6 +11331,7 @@
const App = () => { const App = () => {
const { token, user, logout } = useAuth(); const { token, user, logout } = useAuth();
const [page, setPage] = useState('fundraising-grid'); const [page, setPage] = useState('fundraising-grid');
const [accountMenuOpen, setAccountMenuOpen] = useState(false); // mobile top-bar account popover
const [toasts, setToasts] = useState([]); const [toasts, setToasts] = useState([]);
const [sidebarHidden, setSidebarHidden] = useState(false); const [sidebarHidden, setSidebarHidden] = useState(false);
const [gridViews, setGridViews] = useState(loadGridViews()); const [gridViews, setGridViews] = useState(loadGridViews());
@@ -11466,9 +11600,23 @@
{page === 'settings' && 'Settings'} {page === 'settings' && 'Settings'}
</div> </div>
</div> </div>
<div className="user-info"> <div className="user-info desktop-only">
{user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''} {user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''}
</div> </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>
<div className="content"> <div className="content">
@@ -11533,6 +11681,28 @@
)} )}
</div> </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> </div>
); );
}; };