Mobile UX batch 1: clear buttons, tappable contacts, pipeline swipe + amounts, keyboard-safe sheets (v0.1.0:101)

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.
This commit is contained in:
Keysat
2026-06-20 15:28:13 -05:00
parent 622d454461
commit b04f83e1d1
4 changed files with 204 additions and 21 deletions
+167 -17
View File
@@ -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 }) => (
<div className="input-clear-wrap">
{/* 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. */}
<input className={`${className || ''}${value ? ' has-clear' : ''}`} value={value} {...rest} />
{value ? (
<button type="button" className="input-clear-btn" aria-label="Clear"
tabIndex={-1} onMouseDown={(e) => e.preventDefault()} onClick={onClear}>×</button>
) : null}
</div>
);
/* ─── 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. */}
<BottomSheet open={investorPicker} onClose={() => setInvestorPicker(false)} title="Choose investor" stacked>
<input className="sheet-input" value={investorQuery} onChange={(e) => setInvestorQuery(e.target.value)} placeholder="Search investor…" autoFocus />
<ClearableInput className="sheet-input" value={investorQuery} onChange={(e) => setInvestorQuery(e.target.value)} onClear={() => setInvestorQuery('')} placeholder="Search investor…" autoFocus />
<div className="rem-investor-list">
<button type="button" className="sheet-option" onClick={() => pickInvestor(null)}>
<span>No investor — team task</span>
@@ -5820,7 +5896,7 @@
);
};
const MobileContactsPage = ({ token, onShowToast, onOpenInGrid }) => {
const MobileContactsPage = ({ token, onShowToast, onOpenInGrid, uiAction, onUiActionHandled }) => {
const [contacts, setContacts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -5888,6 +5964,21 @@
return Array.from(m.entries());
}, [filtered, sort]);
// Grid→Contacts deep-link (#2): once the directory has loaded, open the matching contact's
// detail by email (preferred) or name; if there's no exact match, fall back to filtering the
// list. One-shot — cleared via onUiActionHandled so it can't re-fire on later renders.
useEffect(() => {
if (!uiAction || uiAction.type !== 'open-contact' || loading) return;
const email = (uiAction.email || '').trim().toLowerCase();
const name = (uiAction.name || '').trim().toLowerCase();
let match = null;
if (email) match = contacts.find((c) => (c.email || '').trim().toLowerCase() === email);
if (!match && name) match = contacts.find((c) => displayName(c).trim().toLowerCase() === name);
if (match) setSelected(match);
else setSearch(uiAction.email || uiAction.name || '');
if (onUiActionHandled) onUiActionHandled();
}, [uiAction, loading, contacts, onUiActionHandled]);
const renderCard = (c) => {
const org = c.organization || c.organization_name || '';
// Existing-LP ring + stage pill come from the grid signals the API injects (committed,
@@ -5917,12 +6008,13 @@
<div className="mobile-screen">
<div className="mobile-caption">Read-only directory — people are added and edited from the Fundraising Grid.</div>
<div className="mobile-toolbar">
<input
<ClearableInput
className="mobile-search"
type="text"
placeholder="Search contacts…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch('')}
/>
<div className="mobile-sortbar">
<span className="mobile-count">{filtered.length} {filtered.length === 1 ? 'contact' : 'contacts'}</span>
@@ -6520,6 +6612,7 @@
const [busy, setBusy] = useState(false);
const [sortKey, setSortKey] = useState('name'); // PIPELINE_SORTS — applied within each stage
const [sortOpen, setSortOpen] = useState(false);
const [amountDraft, setAmountDraft] = useState(''); // #4b — editable expected amount in the detail
const swipeRef = useRef(null);
const stages = PIPELINE_STAGES;
@@ -6567,6 +6660,12 @@
const openDetail = (oppId) => { setSelectedId(oppId); setContactDetail(null); setDetailOpen(true); };
const closeDetail = () => { setDetailOpen(false); setLogOpen(false); setTimeout(() => setSelectedId(null), 280); };
// #4b — seed the amount field when a deal's detail opens. Keyed on selectedId so a
// post-save opportunities refresh (same id) doesn't clobber what's typed; 0 shows blank.
useEffect(() => {
if (selectedOpp) setAmountDraft(selectedOpp.expected_amount ? String(selectedOpp.expected_amount) : '');
}, [selectedId]);
// Pull the linked contact's communications (+ committed) for the detail sheet's
// notes timeline / stat tiles. Non-fatal on failure — the opp fields still render.
// A `cancelled` guard drops a stale response so rapidly opening card A then B can't
@@ -6598,6 +6697,21 @@
} finally { setBusy(false); }
};
// #4b — persist the edited expected amount via the shared opportunities PUT (the field
// allowlist includes expected_amount; authenticated, not admin-only). Updates the local
// list so the card amount + stage totals reflect it without a refetch.
const saveAmount = async () => {
const opp = selectedOpp; if (!opp) return;
const val = parseNumericInput(amountDraft);
setBusy(true);
try {
await api(`/api/opportunities/${opp.id}`, { method: 'PUT', body: JSON.stringify({ expected_amount: val }) }, token);
setOpportunities((os) => os.map((o) => (o.id === opp.id ? { ...o, expected_amount: val } : o)));
onShowToast('Amount updated', 'success');
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to update amount'), 'error'); }
finally { setBusy(false); }
};
// Log a communication against the open opp's contact (POST /api/communications), then
// refresh the timeline. Same write the Contacts detail uses; carries opportunity_id here.
const submitLog = async ({ type, subject, body }) => {
@@ -6668,7 +6782,7 @@
};
return (
<div className="mobile-screen">
<div className="mobile-screen pipeline-screen">
{loading ? (
<SkeletonBlock lines={8} />
) : error ? (
@@ -6764,6 +6878,17 @@
</div>
</div>
{/* #4b — editable expected amount (writes the shared opportunities row). */}
<div className="sheet-field" style={{ marginTop: '18px' }}>
<label className="sheet-field-label">Expected amount</label>
<div className="amount-edit-row">
<input className="sheet-input" type="text" inputMode="numeric" value={amountDraft}
onChange={(e) => setAmountDraft(e.target.value)} placeholder="e.g. 250000" />
<button className="amount-save-btn" disabled={busy || parseNumericInput(amountDraft) === (Number(opp.expected_amount) || 0)}
onClick={saveAmount}>Save</button>
</div>
</div>
<div className="fs-section-label" style={{ margin: '18px 0 9px' }}>Move stage</div>
<div className="move-stage-list">
{stages.map((st) => (
@@ -6788,7 +6913,7 @@
)}
{dealParts && <div className="sheet-footnote">{dealParts}</div>}
<div className="sheet-footnote">Stage moves and logged communications both write the shared opportunities row — the same data the Grid edits. Amounts stay read-only on mobile.</div>
<div className="sheet-footnote">Stage moves, amount edits, and logged communications all write the shared opportunities row — the same data the Grid edits.</div>
<LogCommunicationSheet open={logOpen} onClose={() => setLogOpen(false)} onSubmit={submitLog} busy={busy} forLabel={opp.name} />
</BottomSheet>
@@ -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 @@
</button>
<button className="grid-new-btn" onClick={() => { setCreateForm({ name: '', contactName: '', contactEmail: '', note: '', priority: false, stage: '', reminderTitle: '', reminderDue: '' }); setSheet('create'); }}>+ New</button>
</div>
<input className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} />
<ClearableInput className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} onClear={() => setSearch('')} />
<div className="mobile-sortbar">
<span className="mobile-count">{displayed.length} {displayed.length === 1 ? 'investor' : 'investors'}</span>
<SortPill label={sortPillLabel(GRID_SORTS, sortKey)} onClick={() => setSheet('sort')} />
@@ -10686,7 +10814,7 @@
stage chip inline (or "Not in pipeline") + chevron → stage sheet. */}
<div className="fs-section">
<div className="fs-section-label">Pipeline stage</div>
<button className="detail-tap-card" onClick={() => setSheet('stage')}>
<button className="detail-tap-card" onClick={() => { setStageAmount(''); setSheet('stage'); }}>
<span className="detail-tap-card-left">
{row.pipeline ? <StageChip stage={row.pipeline_stage} /> : <span className="detail-tap-card-empty">Not in pipeline</span>}
</span>
@@ -10707,8 +10835,12 @@
<div className="fs-section-label">Contacts</div>
{contacts.length ? contacts.map((c, i) => (
<div className="fs-pill" key={i}>
<div className="fs-pill-name">{c.name || '—'}</div>
{c.email && <div className="fs-pill-email">{c.email}</div>}
{/* #2 — name jumps to the contact's directory detail; email opens the mail app. */}
<button type="button" className="fs-pill-name-btn" onClick={() => onOpenContact && onOpenContact(c)}>
<span className="fs-pill-name">{c.name || '—'}</span>
<span className="fs-pill-go">View </span>
</button>
{c.email && <a className="fs-pill-email fs-pill-mail" href={`mailto:${c.email}`}>{c.email}</a>}
</div>
)) : <div style={{ color: 'var(--text-subtle)', fontSize: '13px' }}>No contacts yet.</div>}
</div>
@@ -10761,6 +10893,14 @@
<LogCommunicationSheet open={sheet === 'note'} onClose={closeSheet} onSubmit={submitNote} busy={busy} forLabel={row.investor_name || 'this investor'} />
<BottomSheet open={sheet === 'stage'} onClose={closeSheet} title="Pipeline stage">
{!row.pipeline && (
<div className="sheet-field">
<label className="sheet-field-label">Expected amount (optional)</label>
<input className="sheet-input" type="text" inputMode="numeric" value={stageAmount}
onChange={(e) => setStageAmount(e.target.value)} placeholder="e.g. 250000" />
<div className="sheet-subcaption" style={{ marginTop: '6px' }}>Pick a stage below to add this investor to the pipeline. You can edit the amount later from the Pipeline card.</div>
</div>
)}
{PIPELINE_STAGES.map((st) => (
<button key={st} className={`sheet-option ${row.pipeline_stage === st ? 'active' : ''}`} onClick={() => applyStage(st)} disabled={busy}>
<span>{pipelineStageLabel(st)}</span>
@@ -14522,7 +14662,7 @@
{!target ? (
<>
<div className="quicklog-hint">Pick an investor, then log the communication.</div>
<input className="sheet-input" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search investor or contact…" />
<ClearableInput className="sheet-input" value={search} onChange={(e) => setSearch(e.target.value)} onClear={() => setSearch('')} placeholder="Search investor or contact…" />
<div className="quicklog-pool">
{loading ? <div className="quicklog-empty">Loading…</div>
: pool.length === 0 ? <div className="quicklog-empty">No matches.</div>
@@ -14594,6 +14734,7 @@
const [gridViews, setGridViews] = useState(loadGridViews());
const [activeGridView, setActiveGridView] = useState('view-main');
const [gridUiAction, setGridUiAction] = useState(null);
const [contactsUiAction, setContactsUiAction] = useState(null);
// Open-in-Grid deep-link (8h) — shared by the Contacts, Pipeline, and Reminders detail
// surfaces. Sets a one-shot grid action (an object the desktop grid's string-equality
// branches ignore) and switches to the grid, which opens that investor's detail on mount.
@@ -14601,6 +14742,14 @@
if (rowId) setGridUiAction({ type: 'open-investor', rowId });
setPage('fundraising-grid');
};
// Grid→Contacts deep-link (reverse of Open-in-Grid) — tapping a contact name in the mobile
// Grid investor detail jumps to the Contacts surface and opens that person's detail, matched
// by email (preferred) or name. One-shot; MobileContactsPage consumes it on load.
const openContactDetail = (contact) => {
if (!contact) return;
setContactsUiAction({ type: 'open-contact', email: contact.email || '', name: contact.name || '' });
setPage('contacts');
};
const [sidebarContextMenu, setSidebarContextMenu] = useState(null);
const [draggingViewId, setDraggingViewId] = useState(null);
const [dragOverViewId, setDragOverViewId] = useState(null);
@@ -14902,10 +15051,11 @@
setViews={setGridViews}
uiAction={gridUiAction}
onUiActionHandled={() => setGridUiAction(null)}
onOpenContact={openContactDetail}
/>
)}
{page === 'dashboard' && <DashboardPage token={token} />}
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} />}
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} uiAction={contactsUiAction} onUiActionHandled={() => setContactsUiAction(null)} />}
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} />}
{page === 'reminders' && <RemindersPage token={token} user={user} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} />}
{page === 'communications' && <CommunicationsPage token={token} user={user} onShowToast={showToast} />}
+3 -2
View File
@@ -65,8 +65,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
// * 0.1.0:97 (mobile top-bar polish + native zoom behaviour. Viewport meta gains maximum-scale=1 + user-scalable=no: kills pinch-zoom AND the iOS auto-zoom-on-focus that jerked the page in on every <16px input tap [app-wide, not just login]; OS accessibility zoom still works. Top-bar account initial now flex-centered + dc-aligned [IBM Plex Mono, accent-light, 13px — was defaulting to inline/baseline, off-center]. Quick-log pencil bumped --text-muted→--text-secondary for real affordance [the dc t3 grey thin-outline read as empty next to the color sun emoji on-device]. CSS-only; no JS/schema change)
// * 0.1.0:98 (business-card intake [Matrix bot] captures a contact's phone, mobile/cell, city + LinkedIn from a scanned card onto the contact record — cell to Mobile, office to Phone, fax skipped. Server half: _upsert_contact_from_fundraising now accepts phone+mobile on the contact dict [city+linkedin already worked]. The bot's transcription/extraction/card changes ship on the Spark [git pull + rebuild]. No schema change [contacts columns already exist]; no user-facing CRM change)
// * 0.1.0:99 (Grant device-test round 2, CRM half: intake fuzzy match scores DISTINCTIVE tokens only [no more "Investment Group"/"Capital"/"Family Office" false look-alikes]; mobile grid "Last contact"/staleness sort is reversible; mobile Edit-investor prefills a contact's email [GET /api/fundraising/state heals a blank grid pill from the linked classic contact, fill-only]; mobile quick-log pencil icon renders [CSS sizing on the sole flex-child svg]. The Matrix intake thread-redaction change ships on the Spark, not here. No schema change; no migration)
// * Current: 0.1.0:100 (In-app business-card intake [#7]: a mobile camera button [left of the quick-log pencil] takes/picks a card photo, downscales it client-side via <canvas> to JPEG, and POSTs to the new POST /api/intake/card — vision-transcribe + parse + fuzzy-match on the box [local VL via Spark Control, nothing to Claude], reusing the Matrix card flow's nio-free parse/spark core. An editable review sheet [proposal fields + existing-investor picker] writes via log-communication tagged source="app_card"; a human approves every write. No schema change; no migration; no new dependency)
export const PACKAGE_VERSION = '0.1.0:100'
// * 0.1.0:100 (In-app business-card intake [#7]: a mobile camera button [left of the quick-log pencil] takes/picks a card photo, downscales it client-side via <canvas> to JPEG, and POSTs to the new POST /api/intake/card — vision-transcribe + parse + fuzzy-match on the box [local VL via Spark Control, nothing to Claude], reusing the Matrix card flow's nio-free parse/spark core. An editable review sheet [proposal fields + existing-investor picker] writes via log-communication tagged source="app_card"; a human approves every write. No schema change; no migration; no new dependency)
// * Current: 0.1.0:101 (Mobile UX batch 1 [Grant device feedback]: [1] inline ✕ clear button on the Grid/Contacts search + reminder/quick-log investor pickers [ClearableInput]; [2] Grid investor-detail contact pills are tappable — name deep-links to the Contacts detail [new Grid→Contacts one-shot action], email opens mailto; [4a] 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 scrolling its cards; [4b] expected-amount entry — optional amount when adding to the pipeline from the Grid detail [feeds pipeline/link], editable amount on the Pipeline card detail [PUT /api/opportunities/{id}]; [5] bottom sheets lift above the on-screen keyboard [visualViewport] so the reminder investor-picker results stay visible. Grid contact-name search [#3] already worked. CSS+React only; no schema change; no migration; no new dependency)
export const PACKAGE_VERSION = '0.1.0:101'
export const DATA_MOUNT_PATH = '/data'
export const WEB_PORT = 8080
+3 -2
View File
@@ -61,8 +61,9 @@ import { v_0_1_0_97 } from './v0.1.0.97'
import { v_0_1_0_98 } from './v0.1.0.98'
import { v_0_1_0_99 } from './v0.1.0.99'
import { v_0_1_0_100 } from './v0.1.0.100'
import { v_0_1_0_101 } from './v0.1.0.101'
export const versionGraph = VersionGraph.of({
current: v_0_1_0_100,
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99],
current: v_0_1_0_101,
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99, v_0_1_0_100],
})
+31
View File
@@ -0,0 +1,31 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Mobile UX batch 1 — Grant device feedback. Frontend-only (CSS + React); no backend, schema,
// migration, or dependency change.
// - [1] Inline ✕ clear button on the Grid/Contacts search + the reminder/quick-log investor
// pickers (a shared ClearableInput; the ✕ shows only when there's text).
// - [2] Grid investor-detail contact pills are tappable: the name deep-links to the Contacts
// detail (a new Grid→Contacts one-shot action, matched by email then name), the email
// opens the mail app (mailto:).
// - [3] (already worked) Grid search already matches a contact's name/email, surfacing the
// investor — verified, no change.
// - [4a] Mobile Pipeline is a full-height flex column: the swipe area fills everything above the
// now bottom-pinned page dots, so a horizontal swipe anywhere switches stages; each stage
// page scrolls its own cards.
// - [4b] Expected-amount entry: an optional amount when adding an investor to the pipeline from
// the Grid detail (feeds pipeline/link), and an editable amount on the Pipeline card detail
// (PUT /api/opportunities/{id} — authenticated, expected_amount is in the field allowlist).
// - [5] Bottom sheets lift above the on-screen keyboard (visualViewport) and cap their height to
// the visible area, so the reminder investor-picker results are no longer hidden.
export const v_0_1_0_101 = VersionInfo.of({
version: '0.1.0:101',
releaseNotes: {
en_US: [
'Mobile polish: clear (✕) buttons on search fields, tappable contacts in the Grid detail',
'(name → contact, email → mail app), a full-screen swipe area on the Pipeline with the dots',
'pinned to the bottom, expected-amount entry when adding/viewing a pipeline deal, and',
'investor-picker suggestions that stay visible above the keyboard.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})