From b04f83e1d1cf3b9c8b22354f8f9ef342df77f36b Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 20 Jun 2026 15:28:13 -0500 Subject: [PATCH] Mobile UX batch 1: clear buttons, tappable contacts, pipeline swipe + amounts, keyboard-safe sheets (v0.1.0:101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grant device feedback, frontend-only (CSS + React); no backend, schema, migration, or dependency change. - Clear (×) button on the Grid/Contacts search + reminder/quick-log investor pickers (shared ClearableInput; the × shows only when there's text). - Grid investor-detail contact pills are tappable: name deep-links to the Contacts detail (new Grid→Contacts one-shot action, matched by email then name), email opens the mail app (mailto:). - Grid contact-name search already surfaced the investor — verified, no change. - Mobile Pipeline is a full-height flex column so the whole area above the now bottom-pinned dots is the swipe target; each stage page scrolls its cards. - Expected-amount entry: optional amount when adding to the pipeline from the Grid detail (feeds pipeline/link), and an editable amount on the Pipeline card detail (PUT /api/opportunities/{id}). - Bottom sheets lift above the on-screen keyboard (visualViewport) and cap their height to the visible area, so the reminder picker results stay visible. --- frontend/index.html | 184 ++++++++++++++++++++-- start9/0.4/startos/utils.ts | 5 +- start9/0.4/startos/versions/index.ts | 5 +- start9/0.4/startos/versions/v0.1.0.101.ts | 31 ++++ 4 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 start9/0.4/startos/versions/v0.1.0.101.ts diff --git a/frontend/index.html b/frontend/index.html index 01f88d4..b7a4f07 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2249,6 +2249,19 @@ font-size: var(--mobile-font-body); font-family: inherit; padding: 0 12px; } .mobile-search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } + /* Inline clear (✕) for single-line search/picker fields (ClearableInput). The wrapper + keeps the field full-width; the field reserves right padding so text never sits under + the button, which only renders when there's text. */ + .input-clear-wrap { position: relative; display: block; width: 100%; } + .input-clear-wrap input.has-clear { padding-right: 40px; } + .input-clear-btn { + position: absolute; top: 50%; right: 7px; transform: translateY(-50%); + width: 26px; height: 26px; border-radius: 50%; border: none; padding: 0; + background: var(--bg-hover); color: var(--text-muted); + font-size: 17px; line-height: 1; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + } + .input-clear-btn:active { color: var(--text-primary); } .mobile-seg { display: flex; gap: 6px; } .mobile-seg-tab { flex: 1; min-height: var(--mobile-touch-target); @@ -2624,8 +2637,14 @@ } .fs-action-btn:active { background: var(--bg-hover); } .fs-pill { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 9px 12px; margin-bottom: 8px; } - .fs-pill-name { font-size: var(--mobile-font-body); color: var(--text-primary); } + .fs-pill-name { font-size: var(--mobile-font-body); color: var(--text-primary); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .fs-pill-email { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); margin-top: 2px; word-break: break-word; } + /* #2 — tappable contact pill: name button (→ contact detail) + mailto email link. */ + .fs-pill-name-btn { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; background: none; border: none; padding: 0; font-family: inherit; text-align: left; cursor: pointer; color: inherit; } + .fs-pill-name-btn:active .fs-pill-name { color: var(--accent-light); } + .fs-pill-go { flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--accent-light); } + .fs-pill-mail { display: inline-block; color: var(--accent-light); text-decoration: none; } + .fs-pill-mail:active { text-decoration: underline; } /* G4/G5 — tappable detail cards (Grid full-screen detail): single-tap stage + reminder cards matching the dc stage/reminder anatomy (panel card, inline chip/note, chevron). */ .detail-tap-card { @@ -2726,13 +2745,23 @@ .pipeline-seg-tab.active { background: var(--seg-bg); border-color: var(--seg-border); color: var(--seg-text); } .pipeline-seg-tab.active .pipeline-seg-count { background: var(--seg-border); color: var(--seg-text); } + /* Full-height pipeline layout (#4a) — the screen is a flex column filling .content, so the + swipe area (flex:1) grows to cover everything above the bottom-pinned dots; an entire + stage page (including the empty space below its cards) becomes the horizontal-swipe target, + and each page scrolls its own cards vertically. Scoped to the pipeline (other mobile + surfaces keep their natural grow-and-let-.content-scroll behavior). */ + .pipeline-screen { height: 100%; display: flex; flex-direction: column; min-height: 0; } .pipeline-swipe { + flex: 1 1 auto; min-height: 0; display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scroll-behavior: smooth; scrollbar-width: none; margin-top: 14px; } .pipeline-swipe::-webkit-scrollbar { display: none; } - .pipeline-stage-page { flex: 0 0 100%; width: 100%; box-sizing: border-box; scroll-snap-align: start; padding: 0 1px; } + .pipeline-stage-page { + flex: 0 0 100%; width: 100%; box-sizing: border-box; scroll-snap-align: start; + padding: 0 1px 8px; overflow-y: auto; -webkit-overflow-scrolling: touch; + } /* Stage-column header (P6): stage chip + investor count + committed sum */ .pipeline-page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 4px 2px 12px; } .pipeline-page-head-left { display: flex; align-items: center; gap: 9px; min-width: 0; } @@ -2775,7 +2804,17 @@ .stage-move-btn.back { border-right: 1px solid var(--divider); color: var(--text-muted); } .stage-move-btn:disabled { color: var(--text-subtle); opacity: 0.4; cursor: default; } .stage-move-btn:active:not(:disabled) { background: var(--bg-hover); } - .pipeline-dots { display: flex; justify-content: center; align-items: center; gap: 9px; padding: 8px 0 4px; } + .pipeline-dots { flex: none; display: flex; justify-content: center; align-items: center; gap: 9px; padding: 10px 0 2px; } + /* #4b — inline amount editor (field + Save) in the pipeline card detail. */ + .amount-edit-row { display: flex; gap: 8px; align-items: stretch; } + .amount-edit-row .sheet-input { flex: 1; min-width: 0; } + .amount-save-btn { + flex: none; padding: 0 18px; height: var(--mobile-input-h); + border: none; border-radius: var(--mobile-control-radius); + background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%); + color: #fff; font-size: 14px; font-weight: 600; font-family: inherit; cursor: pointer; + } + .amount-save-btn:disabled { opacity: 0.5; cursor: default; } .pipeline-dot-btn { background: none; border: none; cursor: pointer; padding: 7px 3px; display: flex; align-items: center; justify-content: center; } .pipeline-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--border-strong); transition: width 0.15s ease, background 0.15s ease; } .pipeline-dot.active { width: 22px; background: var(--accent); } @@ -4233,6 +4272,24 @@ return () => window.removeEventListener('keydown', onKey); }, [open, onClose]); + // Lift the sheet above the on-screen keyboard (#5). iOS leaves a fixed bottom:0 element + // behind the keyboard, hiding inputs/results near the sheet's bottom (the reminder + // investor picker). visualViewport gives the keyboard height; we offset `bottom` by it + // and cap max-height to the visible area so the sheet body scrolls instead of clipping. + const [kb, setKb] = useState({ inset: 0, vh: 0 }); + useEffect(() => { + const vv = window.visualViewport; + if (!open || !vv) { setKb({ inset: 0, vh: 0 }); return undefined; } + const onResize = () => { + const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); + setKb({ inset, vh: vv.height }); + }; + onResize(); + vv.addEventListener('resize', onResize); + vv.addEventListener('scroll', onResize); + return () => { vv.removeEventListener('resize', onResize); vv.removeEventListener('scroll', onResize); }; + }, [open]); + if (!mounted) return null; const onPointerDown = (e) => { @@ -4252,7 +4309,10 @@ if (shouldClose) onClose(); }; - const sheetStyle = dragY > 0 ? { transform: `translateY(${dragY}px)`, transition: 'none' } : undefined; + const kbStyle = kb.inset > 0 ? { bottom: `${kb.inset}px`, maxHeight: `${Math.max(220, kb.vh - 8)}px` } : null; + const sheetStyle = (kbStyle || dragY > 0) + ? { ...(kbStyle || {}), ...(dragY > 0 ? { transform: `translateY(${dragY}px)`, transition: 'none' } : {}) } + : undefined; return ( <> @@ -4300,6 +4360,22 @@ ); }; + // Single-line input with an inline ✕ to clear it (mobile search + picker fields). The clear + // button only renders when there's text; onMouseDown-preventDefault keeps the field focused + // (and the keyboard up) so a clear-then-retype flow doesn't dismiss the keyboard. Reuses the + // field's own className (.mobile-search / .sheet-input) for styling; passes the rest through. + const ClearableInput = ({ value, onClear, className, ...rest }) => ( +
+ {/* has-clear (the right padding for the ✕) is added only when there's text, so an + empty field keeps symmetric padding — the reservation appears with the button. */} + + {value ? ( + + ) : null} +
+ ); + /* ─── Shared grid helpers (Phase 3 — mobile Fundraising Grid) ───────────────────── Pure functions so the mobile card list filters rows the SAME way the desktop grid's @@ -5451,7 +5527,7 @@ {/* Investor picker — stacked over the add sheet (dc add-flow :416-428). Selecting sets a canonical grid row id; "team task" clears it. */} setInvestorPicker(false)} title="Choose investor" stacked> - setInvestorQuery(e.target.value)} placeholder="Search investor…" autoFocus /> + setInvestorQuery(e.target.value)} onClear={() => setInvestorQuery('')} placeholder="Search investor…" autoFocus />
+
+ +
Move stage
{stages.map((st) => ( @@ -6788,7 +6913,7 @@ )} {dealParts &&
{dealParts}
} -
Stage moves and logged communications both write the shared opportunities row — the same data the Grid edits. Amounts stay read-only on mobile.
+
Stage moves, amount edits, and logged communications all write the shared opportunities row — the same data the Grid edits.
setLogOpen(false)} onSubmit={submitLog} busy={busy} forLabel={opp.name} /> @@ -10208,7 +10333,7 @@ // reminders), NEVER the whole-grid PUT (BRIEF §3a — that would race the 5-person live grid). // Editable here: create investor, edit name + contact pills (P3b, via update-row), log a // note, pipeline stage, set a reminder. Money amounts stay desktop-only (read-only here). - const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView, uiAction, onUiActionHandled }) => { + const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView, uiAction, onUiActionHandled, onOpenContact }) => { const [columns, setColumns] = useState([]); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); @@ -10221,6 +10346,9 @@ const [busy, setBusy] = useState(false); const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '', priority: false, stage: '', reminderTitle: '', reminderDue: '' }); const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' }); + // #4b — optional expected amount captured when ADDING this investor to the pipeline + // (the stage sheet). Existing deals edit their amount on the Pipeline card detail. + const [stageAmount, setStageAmount] = useState(''); // G6 — investor-level communications timeline for the open detail. Fetched on open // (source_row_id → canonical contacts → communications); commsReload re-runs it after a log. const [comms, setComms] = useState([]); @@ -10374,7 +10502,7 @@ // stage — so enforce the picked stage with a follow-up PATCH on the returned opp. const resp = await api('/api/fundraising/pipeline/link', { method: 'POST', body: JSON.stringify({ source_row_id: row.id, contact_index: 0, name: `${row.investor_name || 'Investor'} — Pipeline`, - stage, expected_amount: 0, probability: row.priority ? 55 : 35, fund_name: '', + stage, expected_amount: parseNumericInput(stageAmount), probability: row.priority ? 55 : 35, fund_name: '', }) }, token); const opp = resp && resp.data; if (opp && opp.id && opp.stage !== stage) { @@ -10571,7 +10699,7 @@
- setSearch(e.target.value)} /> + setSearch(e.target.value)} onClear={() => setSearch('')} />
{displayed.length} {displayed.length === 1 ? 'investor' : 'investors'} setSheet('sort')} /> @@ -10686,7 +10814,7 @@ stage chip inline (or "Not in pipeline") + chevron → stage sheet. */}
Pipeline stage
- + {c.email && {c.email}}
)) :
No contacts yet.
}
@@ -10761,6 +10893,14 @@ + {!row.pipeline && ( +
+ + setStageAmount(e.target.value)} placeholder="e.g. 250000" /> +
Pick a stage below to add this investor to the pipeline. You can edit the amount later from the Pipeline card.
+
+ )} {PIPELINE_STAGES.map((st) => (