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 ? (
+ e.preventDefault()} onClick={onClear}>×
+ ) : 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 />
pickInvestor(null)}>
No investor — team task
@@ -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 @@
Read-only directory — people are added and edited from the Fundraising Grid.
-
setSearch(e.target.value)}
+ onClear={() => setSearch('')}
/>
{filtered.length} {filtered.length === 1 ? 'contact' : 'contacts'}
@@ -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 (
-
+
{loading ? (
) : error ? (
@@ -6764,6 +6878,17 @@
+ {/* #4b — editable expected amount (writes the shared opportunities row). */}
+
+
Expected amount
+
+ setAmountDraft(e.target.value)} placeholder="e.g. 250000" />
+ Save
+
+
+
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 @@
{ setCreateForm({ name: '', contactName: '', contactEmail: '', note: '', priority: false, stage: '', reminderTitle: '', reminderDue: '' }); setSheet('create'); }}>+ New
-
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
-
setSheet('stage')}>
+ { setStageAmount(''); setSheet('stage'); }}>
{row.pipeline ? : Not in pipeline }
@@ -10707,8 +10835,12 @@
Contacts
{contacts.length ? contacts.map((c, i) => (
-
{c.name || '—'}
- {c.email &&
{c.email}
}
+ {/* #2 — name jumps to the contact's directory detail; email opens the mail app. */}
+
onOpenContact && onOpenContact(c)}>
+ {c.name || '—'}
+ View ›
+
+ {c.email &&
{c.email} }
)) : No contacts yet.
}
@@ -10761,6 +10893,14 @@
+ {!row.pipeline && (
+
+
Expected amount (optional)
+
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) => (
applyStage(st)} disabled={busy}>
{pipelineStageLabel(st)}
@@ -14522,7 +14662,7 @@
{!target ? (
<>
Pick an investor, then log the communication.
- setSearch(e.target.value)} placeholder="Search investor or contact…" />
+ setSearch(e.target.value)} onClear={() => setSearch('')} placeholder="Search investor or contact…" />
{loading ?
Loading…
: pool.length === 0 ?
No matches.
@@ -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' &&
}
- {page === 'contacts' &&
}
+ {page === 'contacts' &&
setContactsUiAction(null)} />}
{page === 'pipeline' && }
{page === 'reminders' && }
{page === 'communications' && }
diff --git a/start9/0.4/startos/utils.ts b/start9/0.4/startos/utils.ts
index 3c1c11a..2f966ad 100644
--- a/start9/0.4/startos/utils.ts
+++ b/start9/0.4/startos/utils.ts
@@ -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 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 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
diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts
index 0b03212..d6ab010 100644
--- a/start9/0.4/startos/versions/index.ts
+++ b/start9/0.4/startos/versions/index.ts
@@ -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],
})
diff --git a/start9/0.4/startos/versions/v0.1.0.101.ts b/start9/0.4/startos/versions/v0.1.0.101.ts
new file mode 100644
index 0000000..13b8149
--- /dev/null
+++ b/start9/0.4/startos/versions/v0.1.0.101.ts
@@ -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 () => {} },
+})