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:
@@ -107,14 +107,14 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
|
||||
|
||||
## 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 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).
|
||||
- **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).
|
||||
- **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.)
|
||||
- **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 1 — mobile 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).
|
||||
- **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.
|
||||
- **Next (priority order):** 1) **Phase 2 — Contacts surface** (read-only A–Z list + segmented tabs + search → full-screen detail; lands the `<BottomSheet>` component + `useIsMobile()` + 15px bump — the list→detail→sheet validator before the Grid); 2) **Phase 3 — Grid** (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). **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
@@ -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
|
||||
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**).
|
||||
- **Phase 1 — Shared mobile foundation (the only do-once part of the migration).** Extend `:root` with
|
||||
the missing tokens (semantic colors + the `mobile` token group + a `[data-theme="light"]` block); add
|
||||
CSS for the bottom-tab-bar, the bottom-sheet primitive, `env(safe-area-inset-bottom)`, and the 13→15px
|
||||
type bump; build the viewport-gated shell in `App` (bottom 4-tab bar <768px, hide sidebar, top-bar
|
||||
account/logout control). Touches ~2 inline styles.
|
||||
- **Phase 1 — Shared mobile foundation — BUILT 2026-06-19 (deploy pending).** Shipped: `:root` mobile
|
||||
vars (`--mobile-tab-bar-h`/`--mobile-touch-target`/`--mobile-input-h`/`--mobile-sheet-radius`/screen-pad +
|
||||
fonts + `--text-subtle`/`--border-strong`); CSS for the safe-area-aware **`.bottom-tab-bar`**, the
|
||||
**`.bottom-sheet`/`.sheet-scrim`/`.sheet-handle`** primitive (styling), and `.mobile-only`/`.desktop-only`
|
||||
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 2–5); (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 A–Z
|
||||
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
|
||||
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
|
||||
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`
|
||||
|
||||
+15
-2
@@ -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
|
||||
(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
|
||||
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()
|
||||
for r in conn.execute(
|
||||
"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):
|
||||
if not body.get('name') or not body.get('contact_id'):
|
||||
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()
|
||||
conn = get_db()
|
||||
@@ -2988,7 +2997,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
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('probability', 10), body.get('expected_close_date'),
|
||||
body.get('fund_name'), body.get('description'), body.get('next_step'),
|
||||
@@ -3014,6 +3023,10 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
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',
|
||||
'expected_amount', 'probability', 'expected_close_date', 'fund_name',
|
||||
'description', 'next_step', 'owner_id', 'priority', 'lost_reason']
|
||||
|
||||
@@ -149,6 +149,16 @@ def main():
|
||||
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)")
|
||||
|
||||
# ── 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 ──
|
||||
print("\n[read-injection: GET /state exposes read-only pipeline + pipeline_stage]")
|
||||
st, d = _req(port, "GET", "/api/fundraising/state", token)
|
||||
|
||||
@@ -106,10 +106,14 @@ def test_derivations(conn):
|
||||
_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, "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()
|
||||
|
||||
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)
|
||||
level = lambda srid: st.get(srid, (None, "MISSING"))[1]
|
||||
|
||||
+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