A date-less reminder has no urgency — it lands in the "Later"/"No date" bucket,
out of the overdue/today/this-week rollups and the daily digest — so every
create flow now pre-fills the due date to +1 week (editable) and blocks an empty
save. Shared reminderDefaultDue() helper; edit paths also pre-fill the default
for legacy date-less reminders.
Surfaces:
- Mobile: add-investor sheet (date auto-fills when you start the optional
reminder), standalone Reminders "New reminder", Grid-detail "Set a reminder".
- Desktop: Reminders page "+ New reminder", grid reminder modal.
Server still accepts a null due_date by design (bot/automation callers); this is
a human-UI requirement. Frontend-only; no schema/migration/dependency change.
Durable: add the post-8 mobile-feedback primitives to the Design bullet
(ClearableInput, BottomSheet keyboard-lift, the Grid→Contacts deep-link,
the full-height Pipeline layout, editable pipeline expected_amount, and the
admin-only MobileEmailBell as a third surface over email_activity_proposals).
Current state: box now live at v0.1.0:102, deployed + verified; next steps are
Grant's on-device pass of the six items.
Holistic review nit: .sheet-remove (Reject/Delete/Remove) and the bell's Back
button accepted `disabled` but had no dimmed state, unlike .sheet-submit — so a
tap during an in-flight write gave no feedback. Add a 0.5-opacity disabled rule
to both. CSS-only; still v0.1.0:102 (not yet built/deployed).
An admin-only bell in the mobile top bar (left of the camera) surfaces the
SAME pending email-capture proposals the web "Email Capture" panel and the
Matrix review room decide — a third surface over the existing endpoints, no
new backend.
- Count badge (iPhone-style) from a 45s poll of GET /api/activity/proposals;
dim when there are none.
- Tap → card list of proposals → tap one → review screen (investor name,
direction/date, subject, summary, editable proposed note) → Approve & log to
grid (POST .../{id}/approve {note}) or Reject (POST .../{id}/dismiss).
- Bidirectional sync is automatic: an app decision flips the proposal status
and the bot's poll redacts the Matrix thread; a Matrix/web decision drops the
proposal from the pending list the bell polls, clearing the badge.
- No LLM round-trip (edit-then-approve, like the web panel). Mobile-gated
(isMobile && admin) so the hidden desktop top bar never polls the endpoint.
Frontend-only; no schema, migration, or dependency change.
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.
#7 shipped in v100 and verified on the box; Grant device-tested on his phone —
both camera capture and photo-library upload work.
- matrix-intake guide: new "In-app card intake (#7)" subsection (the CRM's own
twin of the Matrix M3 card flow), plus a load-bearing caveat — server.py now
imports parse + spark, so those two must stay nio-free or the CRM image breaks.
- AGENTS.md Current state: rewritten for v100; v99 device fixes confirmed;
next steps reset to the standing mobile on-device gate.
A mobile, in-app twin of the Matrix business-card flow (M3): photograph a
card in the app and it becomes a reviewed fundraising-grid add/note, with a
human approving every write.
Server — POST /api/intake/card (authenticated member+, read-only): lazily
imports the bot's nio-free parse + spark core, vision-transcribes the photo
(local VL via Spark Control — nothing to Claude), runs the same email/phone/
LinkedIn integrity rule + fuzzy matcher, and returns a proposal plus exact
match / fuzzy candidates. No write happens here.
Frontend — a camera button in the mobile top bar (left of the quick-log
pencil) → take or pick a photo → <canvas> downscale to JPEG (also normalizes
iPhone HEIC) → the endpoint → an editable review sheet (proposal fields +
existing-investor picker). Save reuses /api/fundraising/log-communication
tagged source="app_card".
No schema change, no migration, no new dependency, no Matrix-bot change. The
camera/canvas/OCR path is on-device-only (jsdom has no canvas); covered by
test_intake_card.py (stubbed vision+parse) + the render/mount smokes.
reviewer agent flagged the broadened redact_thread predicate (event_id OR in_reply==root)
as over-matching any plain reply to a thread root. Gate the bare-in_reply clause to the bot's
own sender (the nudge is always ours); thread children (cards/acks/human yes-no) still match by
rel_type=m.thread. Add unit edges for _name_similarity's all-generic fallback and a contact_id
NULL orphan case for the grid-blob email heal.
Grant's real-phone testing surfaced seven items; this lands six (the seventh,
in-app camera card intake, is planned in docs/handoffs/in-app-card-intake-plan.md).
CRM half — ships in the s9pk (v0.1.0:99):
- Intake fuzzy match no longer over-indexes on generic firm words. _name_similarity
now compares DISTINCTIVE tokens only (generic descriptors — "Investment Group",
"Capital", "Family Office" — stripped via _GENERIC_ORG_WORDS) for both the difflib
ratio and the Jaccard, so "Fortitude Investment Group" stops surfacing Aether/Russell
while "Aether Capital" still surfaces "Aether Investment Group". +2 regression cases.
- Mobile grid "Last contact"/staleness sort is reversible. SortSheet gains opt-in
dir/onToggleDir; other surfaces (Contacts/Pipeline) are untouched.
- Mobile "Edit investor" prefills a contact's saved email. GET /api/fundraising/state
heals a blank grid pill email from the linked classic contact
(fundraising_contacts.contact_id -> contacts.email), fill-only, by pill order then
name; the next one-row save persists it. +test_grid_email_heal.py.
- Mobile quick-log pencil icon renders. iOS collapses a sole, centered, attribute-only
-sized flex-child <svg>; .quicklog-btn svg now gets explicit CSS width/height + flex:none
(the pattern the working bottom-tab/sort-pill icons use). The v97 fix only changed color.
Matrix intake bot — ships on the Spark (bot-only, NOT the s9pk):
- Approve/reject now redacts the whole intake thread (card + ack + main-timeline nudge +
the user's own photo/note), mirroring the email-review room; redact_thread takes the
room as an arg and matches replies by m.thread OR m.in_reply_to (so the nudge clears).
No more in-Matrix confirmation after a commit (the thread vanishing is the ack).
Needs the bot to hold a redact/moderator power level in the intake room.
- New one-time backend/matrix_intake/redact_intake.py clears the room's pre-existing
backlog (dry-run default; --apply).
Tests 42/42 green; frontend render-smoke green. Frontend fixes are inspection + render
-smoke verified (on-device confirm pending); the bot redaction is live-smoke only.
Completes business-card contact capture. The transcription prompt now labels
Phone/Mobile/Fax on separate lines, and the extractor maps an office/main number ->
phone and a cell -> mobile, never a fax. Both carry the same digit-in-source
integrity rule as email/LinkedIn: a number is kept only if its digits literally
appear in the source (or, on revise, the instruction) -- never minted. The proposal
card shows Phone + Mobile and they're editable (aliases phone/tel/office, mobile/cell).
Server: _upsert_contact_from_fundraising now accepts contact.phone + contact.mobile
and writes them to the canonical contact record (contact-level, not grid pills),
shipped in s9pk v0.1.0:98. No schema change -- the contacts columns already exist.
41/41 backend suite green + the matrix_intake units; card flow end-to-end is live-smoke.
_upsert_contact_from_fundraising now reads contact.phone and writes contacts.phone on
both the insert and update paths, so a phone captured from a business card persists on
the canonical contact record. Phone stays contact-level (not a grid pill field),
matching how the team edits it. Validated by test_grid_add_investor.py.
This is the SERVER half of business-card phone capture, staged for the next s9pk
(version bump + build + install). The bot's phone extraction/card/payload lands in the
same deploy, so phone never shows on a card before the box can store it. NOT yet
built or installed to the box.
The card transcription prompt now reads emails/URLs/phones character-by-character,
explicitly forbids autocompleting toward a plausible domain (the mara.com ->
marac.com failure), and emits labeled lines (which also feeds the field extractor
cleaner input).
The extractor gains city + linkedin_url. city is a plain field (low-harm if wrong;
the human sees it on the card). linkedin_url follows the email-integrity rule: kept
only if it literally appears in the source / a revise instruction, never minted -- a
wrong profile URL points at the wrong person. Both flow to the contact via the
existing log-communication upsert (city also syncs to the grid contact pill).
Phone is intentionally NOT included yet: the bot's write path can't store it until a
small server-side change lands (next s9pk). See the matrix-intake guide.
The in-thread approval handler already routes any reply that isn't yes/no/
edit-grammar through local Qwen (parse.revise), but the card copy only mentioned
'edit field=value', so the natural-language path was undiscoverable. Lead with
plain-words edits; the deterministic field=value fast-path still works.
The intake bot now accepts a photo of a business card in the intake room and
turns it into the same new-investor proposal a typed note would. The only new
step is image -> text; everything downstream (parse, fuzzy match, in-thread
approval, log-communication write) is reused unchanged.
M3 was deferred only because Spark Control had no vision model. That blocker is
gone: the daily-driver Qwen is vision-capable under the same model id, and the
gateway forwards OpenAI multimodal content untouched, so no gateway/server/s9pk
change is needed -- this ships bot-only (git pull + rebuild on the Spark).
Transcribe-then-reuse (not vision-straight-to-JSON) is deliberate: the
transcription becomes the source text the email-integrity rule checks against,
so a mis-read address can't reach the CRM unapproved -- same guarantee as the
text path. Card commits tag source="matrix_card" for the audit log.
- llm.chat_vision: multimodal /v1/chat/completions, same model, same gateway
- spark.transcribe_card: faithful card->text, "" on a non-card (NONE sentinel)
- bot.on_image/handle_card: download image, transcribe, hand to handle_intake
- crm_client: source provenance overridable via the proposal's _source key
- tests: test_spark.py + a provenance case; 41/41 suite green
First round of Grant's real-phone feedback on the mobile redesign. CSS-only;
desktop untouched.
- Viewport: add maximum-scale=1.0 + user-scalable=no. Disables pinch-zoom and —
the real fix — the iOS auto-zoom-on-focus that jerked the whole page in on every
tap of a sub-16px input (our fields are 13-15px). The mobile surfaces are sized
for phones, so nothing needs zooming; OS-level accessibility zoom still works.
- Top-bar account initial: was rendering off-center because .account-btn lacked
flex centering (it fell back to inline/baseline). Add inline-flex centering and
align to the dc spec (IBM Plex Mono, accent-light, 13px, GridApp.dc:60).
- Quick-log pencil: bump --text-muted -> --text-secondary. Markup/color otherwise
match the dc reference exactly, but the dc's thin grey outline reads as empty
next to the color sun emoji on-device; the brighter neutral gives the action
button real affordance.
Also records the v97 deploy + these items in AGENTS.md Current state.
The v95 mobile-first redesign covered the 4 core surfaces but skipped the
login/first-admin screen, which still used desktop-only `height: 100vh` and a
fixed centered card with no screen gutters or safe-area handling. On an installed
iOS PWA (viewport-fit=cover, fixed black status bar) the centered card could tuck
under the status bar, and on small phones the panel ran edge-to-edge.
CSS-only fix, scoped to the login surface (no markup/JS/schema change; desktop
login untouched):
- `.login-container`: 100vh -> min-height 100dvh (+ vh fallback) so the dynamic
viewport and standalone PWA chrome are respected.
- New <768px media query: 16px screen gutters + env(safe-area-inset) top/bottom
clearance, full-bleed card, and touch-sized fields (inputs 46px/15px, button 46px).
- `.login-card`: add the §4 card depth shadow to match `.section`.
Closes the login-surface half of the known PWA status-bar collision risk.
Mark Current state as deployed at v0.1.0:95 (the whole mobile-first set +
installable PWA shipped + verified on the box), and add a durable Conventions
bullet for the PWA — note the /manifest.webmanifest route must stay pre-auth
and there is no service worker by design.
Ships the previously deploy-pending set in one s9pk: mobile Phases 0–8
(touch-native Grid/Pipeline/Reminders/Contacts, light theme, bottom sheets,
Phase 8 dc conformance incl. 8i shell icons + wordmark), drag-reorder views,
the 4-stage pipeline funnel (in-app migration 0007), and the installable PWA.
Make the app installable to the iOS home screen and launch standalone
(full-screen, no browser chrome, dark status bar). Add manifest.webmanifest,
square app icons (ten31-app-icon.svg -> 192/512/apple-touch-icon), the
apple-mobile-web-app + manifest <head> tags, viewport-fit=cover, and a
pre-auth /manifest.webmanifest route. No service worker by design.
Replace the four bottom-tab emoji glyphs with the dc tabIcon SVGs
(BottomTabIcon, currentColor so they flip with active/theme) and add the
·Ten31· mobile-wordmark to the top bar, making the page title desktop-only.
Closes S1/S2 — Phase 8 (8a–8i) complete; next is deploy + device-test.
Grid full-screen investor detail, conformed to the dc anatomy:
- G4: pipeline stage as a single tappable .detail-tap-card (chip + Change/Add)
- G5: dedicated Reminder card fed by the soonest active reminder; tri-state
(loading → disabled "Checking…" so a pre-load tap can't POST a duplicate;
none → "No reminder set"; object → edit). Edits PATCH in place, else POST.
- G6 (notes timeline) was already in place.
Open-in-Grid deep-link, now on all three mobile detail surfaces (Contacts,
Pipeline, Reminders): a shared shell openInvestorInGrid(rowId) sets a one-shot
gridUiAction object the mobile grid consumes on mount to open that investor's
detail; the desktop grid drains the unrecognized object so it can't linger.
Each surface gets its grid row id from a server-injected source_row_id:
contacts via contact_grid_signals, opportunities via the durable
fundraising_investor_id join, reminders via the investor_id join. All are
read-only/GET-only or field-allowlist writes, so none need a strip point.
Tests: source_row_id injection assertions for contacts, opportunities, and
reminders; full suite 40/40. Client surfaces jsdom-verified.
The mobile "New investor" sheet now captures three optional fields beyond name/contact/note,
matching the dc (GridApp.dc.html:737):
- Initial pipeline stage — a .stage-pick chip picker, defaulting to "Not in pipeline" so a
plain directory add never auto-creates an opportunity row (Grant's call).
- A framed "Flag as Priority" toggle.
- An optional reminder (title + a progressive due-date field).
submitCreate orchestrates one-row calls in order: create (log-communication
create_investor_if_missing, now carrying priority) -> if a stage was picked, link to the
pipeline at that stage (reusing applyStage's idempotent link-then-PATCH) -> if a reminder
title was given, POST /api/reminders keyed on the new row's source_row_id. The link and
reminder steps are non-fatal: a failure toasts but never loses the created investor, and a
create that returns no row id warns instead of a clean success.
Backend: handle_log_fundraising_communication honors an optional priority flag only on its
create-if-missing branch (an existing-row log never touches priority).
Guarded by test_grid_add_investor.py (priority-on-create, defaults-False, the create-branch-
only invariant, and the create->link / create->reminder handshakes on a freshly-synced row).
40/40 backend green; the create sheet was interaction-verified in a throwaway jsdom harness.
Bring the mobile Pipeline surface to the PipelineApp.dc.html default anatomy:
- Segmented control → horizontal-scroll pills with label + count badge; the active
pill tints to its own stage color via --seg-* (aliased to --chip-*, so it flips in light).
- Card → earmark corner + name + Priority pill / $amount · dot · recency / labeled
‹ Prev · Next › move footer (was name + contact·org sub + bare chevrons). Compact amount.
- Stage-column header → StageChip + investor count + committed sum.
- Page dots → tappable, active = 22px accent bar.
Backend: the opportunities list injects two derived read-only fields (mirroring the
Contacts-list pattern; opp writes use a field allowlist so neither round-trips):
- existing_investor (contact_grid_signals committed>0) so the card earmark agrees with
the detail's "Existing LP" pill.
- last_contact_date (MAX communication_date on the deal's contact, deleted_at-filtered)
→ card recency line + the Staleness sort (replaces the updated_at proxy).
Guarded by new soft-delete assertions in test_soft_delete_reads.py. 39/39 green.
DueChip + .due-chip--{bucket}/--due-*-* slots in the chip-var enumeration;
note the stacked investor-picker and the source_row_id -> investor_id
reminder-link convention (PATCH can't reassign).
Re-author the mobile Reminders surface to the dc anatomy: title + urgency
summary line + gradient add (drop Active/Done/All tabs); four urgency buckets
(Overdue/Today/This week/Later) with colored dots; urgency-colored DueChip
pill (new --due-* theme vars, dark+light); collapsible Completed section
(done+cancelled, tap-check reopens). Load all statuses in one call, split
client-side.
Swipe-right opens a snooze preset sheet (+1/+3/+7/+14d) replacing the fixed
+7d. The add flow gains a stacked searchable investor picker over the
canonical grid that writes source_row_id -> a real server-resolved
investor_id link (replacing the free-text label that never linked; team-task
= no investor). Edit stays read-only for the investor (PATCH can't reassign).
Add a shared SortPill + SortSheet (label+hint option rows) and per-surface sort
tables:
- Grid: Name / Pipeline stage / Committed / Last contact / Priority, applied in
the displayed memo (name is the tiebreak; staleness ranks longest-since-contact
first, no-activity treated as most stale; committed uses the fund rollup).
- Pipeline: Name / Amount / Last activity / Priority, sorted within each stage.
"Last activity" uses opp.updated_at as a recency proxy until the Pipeline card
wires true last-contact recency (8f).
- Contacts: drop the investor/prospect type tabs (the prospect type is unused);
add a Priority sort alongside Name A-Z/Z-A and Last contact.
contact_grid_signals() now also surfaces the linked investor's priority flag,
injected on both contact read paths (same derive-on-read contract as committed /
pipeline_stage), powering the Contacts Priority sort. Extended
test_contacts_grid_signals.py covers it; 39/39 backend green.
Grid detail (G6): replace the single row.notes blob with a NoteTimeline fed by
a new investor-level read, GET /api/communications?source_row_id=<grid row id>
(filter maps the grid row -> fundraising_investors.source_row_id ->
fundraising_contacts.contact_id -> communications, soft-delete-respecting;
cancelled-flag fetch + commsReload after a log). Note-logging now uses the
shared LogCommunicationSheet, retiring the bespoke noteForm select sheet and the
dead .fs-note-log style.
New MobileQuickLog: a shell mobile-top-bar pencil reachable from every tab —
two-step sheet (pick investor via search + recent-first pool -> inline log form)
writing through the one-row /api/fundraising/log-communication path.
source_row_id and contact_id are kept mutually exclusive in
handle_list_communications so a future caller passing both can't get the empty
intersection. Guarded by test_grid_comm_timeline.py (cross-contact aggregation,
investor isolation, soft-delete); 39/39 backend green.
8a — Grid card: existing-LP earmark corner-triangle (replaces left-border), right-side
PRIORITY pill (replaces the rejected star), 4-stage chip, zero-commit dim; detail star ->
"Existing LP" pill. Contacts card: two-letter avatar initials + existing-LP ring + stage pill
+ recency; disposition badge dropped. New backend contact_grid_signals() injects derived
read-only committed/pipeline_stage on GET /api/contacts and /api/contacts/{id} (existing-LP
ring + stage pill); read-only directory, so no strip-point. DESIGN.md §4/§8 reconciled.
8b — Contacts and Pipeline detail surfaces converted from full-screen to drag-dismiss bottom
sheets matching the .dc.html anatomy: Contacts gets an email-copy pill, Log/Email actions, and
an Organization card; Pipeline gets stat tiles, an inline move-stage list, and a notes timeline
+ Log sheet. Both log via POST /api/communications; BottomSheet gains a `stacked` prop to layer
the Log sheet over a detail. Reviewer fixes: cancelled-flag fetch guards (stale-response race),
keyed single-contact signals query, multi-investor dedup test.
All deploy-pending (no s9pk built); not device-tested. 38/38 backend tests green.
design/phase8-conformance.md — per-surface anatomy + deltas + line refs built
against the .dc.html defaults (not the screenshots), as the 8a–8i build reference.
Corrects the screenshot read (drop disposition badges + contact footer; 4-stage
funnel + swipe dirs already correct). Records two Grant decisions (2026-06-19):
Pipeline existing-LP uses the earmark (unify with Grid, overriding the dc star);
Contacts drops the investor/prospect type tabs (prospect type unused) but keeps a
Priority-flag sort. Refresh AGENTS.md Current state; ROADMAP Phase 8 points at the spec.
This session audited the mobile surfaces vs the Claude Design export
(functional-parity + visual-conformance) and corrected the design source of
truth: the *.dc.html prototypes at their default data-props
(compact/dark/plex/earmark) are canonical, not the screenshots (option-history).
Adds the 9-phase Phase 8 conformance plan to ROADMAP, a durable gotcha to the
Design convention, and prunes Current state to the live state.
Route remaining hardcoded UI colors through themed :root vars so they flip
under [data-theme="light"], finishing the P6 light-theme migration.
- Mobile: bottom-tab-bar -> --nav-bg; toast bottom-center above the tab bar,
rising in via a new slideInUp keyframe.
- Money: --money for the kanban/pipeline-stage/stat amount literals.
- Badges: remap the legacy Material .badge-* family onto the brand
StageChip/--chip-* + --badge-priority/-danger slots, retiring the second
palette (DESIGN sections 2/7).
- Desktop-light: logout/danger buttons, table hover/stripe/footer, sidebar
& header gradients, scrollbars, toast states, context-menu danger, thesis
banner/accents, grid selection & drag indicators, skeleton loaders.
28 new themed vars (theme-stable ones dark-only); dark appearance preserved.
Frontend-only. Verified via CSSOM render-smoke, var resolution, brace check;
reviewer approve-with-nits (toast-rise fixed). Unverified on a real device.
Also refresh AGENTS.md Current state and add a ROADMAP legacy-usage-sweep note.
Adds the editable half of BRIEF §3a's mobile grid set: rename an investor
and add/edit/remove its contact pills from the mobile detail sheet.
New POST /api/fundraising/update-row is the one-row read-fresh-modify-write
twin of log-communication: it mutates only the target row's name/contacts in
the canonical grid blob server-side, then bumps the version + re-syncs the
relational tables. It never accepts a whole-grid payload, so a stale mobile
client can't clobber concurrent edits to other rows (the reason mobile avoids
the whole-grid PUT). _sanitize_fundraising_contacts whitelists the known pill
fields as the trust boundary; removing a pill is soft on the classic contacts
directory (only the grid pill + fundraising_contacts row drop).
Frontend: MobileFundraisingGrid gains an Edit bottom-sheet (name input + pill
editor with client-side dedup); money stays desktop-only. New CSS is
theme-var-only so it flips in light mode.
Verified: test_fundraising_update_row.py (24 assertions, real HTTP), full
suite 37/37, render-smoke + a 375px jsdom interaction harness green.
Ship the light palette behind a :root[data-theme="light"] switch; dark
stays the default and brand identity. A pre-paint boot script applies
localStorage.venture_crm_theme (no flash, no prefers-color-scheme), and an
app-wide toggle lives in the desktop sidebar footer + the mobile top bar,
both driven by one theme state in App.
Method keeps dark mode byte-identical: :root grew to 44 themed color slots
whose dark values equal the original literals, then 319 hex literals were
migrated to var() across the JSX inline region and the <style> block. The
StageChip is now className-based (.stage-chip--{stage}); PIPELINE_STAGE_CHIP
is removed. Every light tint (stage/recency/note/priority/reminder/money)
uses the designer's exact values from the full Claude Design export
(store.js + the four *App.dc.html DCLogic palettes), now committed as
provenance under design/_imports/2026-06-19_zip-file/ (zip + screenshots
gitignored).
Mobile surfaces + chrome are fully var-based, so mobile light is complete.
Desktop light has known rough edges (bespoke <style> shades, the legacy
off-palette .badge-* family, dark-tuned shadows) folded into a new Phase 7
design-conformance pass.
Verified: render-smoke green; a jsdom interaction harness on the authed
shell exercised the toggle (boot-dark -> light+persist+relabel -> dark);
dark-identity, theme-parity, and no-undefined-var checks all green. Not yet
checked on a real phone/browser.
Completes the four mobile-first surfaces. Both phases follow the established
useIsMobile() wrapper → Mobile*/Desktop* pattern; desktop is untouched (only
renamed Desktop*). No backend change.
P4 Pipeline (MobilePipeline): CSS scroll-snap swipe between the four stages +
count-forward segmented control + page dots; per-card ‹/› stage move and
tap → full-screen opp detail with a stage-picker sheet. Opp-centric — shares
PATCH /api/opportunities/{id}/stage with the desktop board and the Grid's
stage edit; view + advance-stage only (removal stays on the Grid/desktop board).
P5 Reminders (MobileReminders): urgency-grouped list over /api/reminders
(Overdue/Due soon/Later/Done/Cancelled) with an Active/Done/All filter; each
row is a pointer-drag swipe (left = mark done, right = snooze +7d, keeping
status open and pushing due_date, per the desktop rationale); tap → create/edit
BottomSheet (investor is create-only, matching the backend PATCH field set).
formatDueShort/reminderDueDelta fix the desktop formatter mis-rendering future
due dates.
Verified: render-smoke + throwaway jsdom 375px interaction harnesses (12/12
each); reviewer passes applied — notably P5 pointercancel no longer fires a
spurious mark-done. Deploy-pending (no s9pk built); not yet tested on a phone.
Adds the mobile-first Fundraising Grid (<768px): a lean MobileFundraisingGrid
that reads /api/fundraising/state once and renders an investor card list over
the active view (name, committed $, pipeline-stage chip, staleness-colored
recency, Existing-Investor accent, Priority corner; graveyard muted) with a
bottom-sheet view picker and search. Tap a card -> full-screen detail with
read-only commitments/contacts/notes plus edit sheets: log a note, pipeline
stage, set a reminder, and a "+ New" investor create flow with client-side
dedup typeahead.
All writes go through the targeted one-row endpoints (log-communication,
pipeline link, opportunities stage PATCH, reminders) — NEVER the whole-grid
PUT, which would race the multi-user grid (BRIEF §3a). FundraisingGridPage is
now a useIsMobile() wrapper over the renamed-but-untouched desktop grid and
the new mobile one (rules-of-hooks-safe; desktop unchanged).
Backend: inject a read-only opportunity_id into grid rows
(opportunity_id_by_source_row; added to both strip points) so the mobile detail
can PATCH a linked opp's stage directly. Earliest-opp-wins ordering keeps it
consistent with pipeline_stage and the link's canonical pick.
Editing an existing investor's name + contact pills stays read-only here
(deferred to P3b — needs a narrow per-row PATCH + pill editor).
Tests: test_grid_pipeline_link extended (opportunity_id inject/strip/round-trip);
36/36 backend green, render-smoke green.