Files
ten31-database/design/_imports/2026-06-19/GridApp.dc.html
T
Keysat 7b560c97b6 Distill mobile-first design round-trip into the contract
Phase C/D of the /design round-trip (Claude Design "Venture-CRM mobile
redesign", 2026-06-19). Captures the cloud output and folds it into the
durable design/ contract; no frontend reskin in this pass.

- _imports/2026-06-19/: provenance — GridApp.dc.html (byte-exact canonical
  surface) + a manifest README (project URL/inventory, data model, derived-
  field formulas, per-surface interaction model). DesignSync can't bulk-
  download, so screenshots/other sources stay recoverable from the cloud URL.
- DESIGN.md: §8 Responsive rewritten to the landed mobile-first system
  (4-tab bottom bar, card/detail, bottom sheets, swipe/snap, safe areas);
  §4 mobile component states; §3 15px mobile type scale; §2 stage/staleness
  + light-theme palette pointers.
- tokens.tokens.json: new `mobile` group (type scale, radii, touch sizing,
  safe-area) + `motion.sheet`; `color.light` palette — light theme adopted
  as a planned, toggle-gated feature (dark stays default).
- ROADMAP.md: mobile-first implementation backlog (contract-vs-code gap),
  gated on the inline-style->CSS migration and the locked pipeline spec.
2026-06-19 11:25:25 -05:00

829 lines
63 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<script src="store.js"></script>
<style>
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes screenIn { from { transform: translateX(14px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
.ga-scroll::-webkit-scrollbar { width: 0; height: 0; }
.ga-root button, .ga-root input, .ga-root textarea, .ga-root select { font-family: inherit; }
.ga-root {
--sans:'IBM Plex Sans','Segoe UI',sans-serif; --mono:'IBM Plex Mono',monospace;
--grad1:#1a3c5e44; --grad2:#27496b33;
--base:#0b1118; --panel:#111a27; --elev:#152233; --input:#0d1622; --hover:#1b2a3a;
--border:#263548; --bstrong:#35506a; --divider:#1c2735;
--t1:#e5edf5; --t2:#c7d3e0; --t3:#8ea2b7; --t4:#70859b;
--accent:#3b82c4; --accentlight:#93c5fd; --danger:#e06c6c; --money:#6ee7b7;
--shadow-card:0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
--nav-bg:#0d1622cc;
}
.ga-root[data-theme="light"] {
--grad1:#3b82c41c; --grad2:#27496b10;
--base:#eaeef3; --panel:#ffffff; --elev:#f4f7fb; --input:#eef2f7; --hover:#e6ecf4;
--border:#d6dde7; --bstrong:#b6c3d4; --divider:#e8edf3;
--t1:#16202c; --t2:#33414f; --t3:#5a6b7d; --t4:#84909e;
--accent:#3b82c4; --accentlight:#1f6fb8; --danger:#c0322f; --money:#057a55;
--shadow-card:0 8px 20px rgba(40,70,110,0.10), inset 0 1px 0 #ffffff;
--nav-bg:#ffffffd9;
}
.ga-root[data-font="manrope"] { --sans:'Manrope','Segoe UI',sans-serif; --mono:'JetBrains Mono',monospace; }
.ga-root[data-font="hanken"] { --sans:'Hanken Grotesk','Segoe UI',sans-serif; --mono:'Spline Sans Mono',monospace; }
</style>
</helmet>
<div class="ga-root" data-theme="{{ themeAttr }}" data-font="{{ fontAttr }}" style="position:absolute; inset:0; background:radial-gradient(900px 460px at 12% -8%, var(--grad1), transparent 60%), radial-gradient(760px 380px at 92% -2%, var(--grad2), transparent 58%), var(--base); display:flex; flex-direction:column; font-family:var(--sans); color:var(--t1); letter-spacing:0.01em; overflow:hidden;">
<!-- status bar -->
<div style="height:46px; flex:none; display:flex; align-items:flex-end; justify-content:space-between; padding:0 24px 6px; font-family:var(--mono); font-size:13px; color:var(--t2);">
<span>9:41</span>
<span style="display:flex; gap:6px; align-items:center; font-size:11px; letter-spacing:0.02em;">5G ▮▮▮▯ 84%</span>
</div>
<!-- top bar -->
<div style="flex:none; height:52px; display:flex; align-items:center; justify-content:space-between; padding:0 16px; border-bottom:1px solid var(--border);">
<span style="font-family:var(--mono); font-weight:600; font-size:15px; letter-spacing:0.04em; color:var(--t1);">·Ten31·</span>
<div style="display:flex; align-items:center; gap:10px;">
<button onClick="{{ openQuickLog }}" aria-label="Log communication" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--border); background:var(--elev); color:var(--t3); cursor:pointer; display:flex; align-items:center; justify-content:center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>
</button>
<button onClick="{{ toggleTheme }}" aria-label="Toggle theme" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--border); background:var(--elev); color:var(--t3); font-size:15px; cursor:pointer; display:flex; align-items:center; justify-content:center; line-height:1;">{{ themeIcon }}</button>
<button onClick="{{ toggleAccount }}" aria-label="Account" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--bstrong); background:var(--elev); color:var(--accentlight); font-family:var(--mono); font-weight:600; font-size:13px; cursor:pointer; display:flex; align-items:center; justify-content:center;">GG</button>
</div>
</div>
<!-- main scroll area -->
<div class="ga-scroll" style="flex:1; min-height:0; overflow-y:auto; overflow-x:hidden;">
<sc-if value="{{ tabGrid }}" hint-placeholder-val="{{ true }}">
<div style="padding:14px 16px 24px;">
<button onClick="{{ openViewSheet }}" style="width:100%; text-align:left; background:none; border:none; padding:0; cursor:pointer; display:flex; align-items:center; gap:8px; color:var(--t1);">
<span style="font-size:21px; font-weight:600; letter-spacing:-0.01em;">{{ view }}</span>
<span style="color:var(--t3); font-size:13px; transform:translateY(1px);"></span>
</button>
<div style="margin-top:5px; display:flex; align-items:center; justify-content:space-between; gap:10px;">
<span style="font-family:var(--mono); font-size:12px; color:var(--t4);">{{ listCountLabel }}</span>
<button onClick="{{ openSortSheet }}" style="flex:none; display:flex; align-items:center; gap:6px; height:30px; padding:0 12px; border-radius:999px; border:1px solid var(--border); background:var(--input); color:var(--t2); font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; cursor:pointer;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h11M3 12h7M3 18h4"></path><path d="M18 8v9m0 0 3-3m-3 3-3-3"></path></svg>
{{ sortLabel }}
</button>
</div>
<div style="display:flex; gap:10px; margin-top:14px;">
<input value="{{ search }}" onInput="{{ onSearch }}" placeholder="Filter investors, contacts…" style="flex:1; min-width:0; height:44px; background:var(--input); border:1px solid var(--border); border-radius:8px; color:var(--t1); font-family:var(--sans); font-size:15px; padding:0 14px; outline:none;" />
<button onClick="{{ openCreate }}" aria-label="Add investor" style="width:44px; height:44px; flex:none; border-radius:8px; border:none; background:linear-gradient(#3b82c4,#2f6ea9); color:#fff; font-size:22px; font-weight:500; line-height:1; cursor:pointer; box-shadow:0 6px 14px rgba(12,40,68,0.35);">+</button>
</div>
<div style="display:flex; flex-direction:column; gap:10px; margin-top:16px;">
<sc-for list="{{ cards }}" as="c" hint-placeholder-count="5">
<button onClick="{{ c.open }}" style="position:relative; overflow:hidden; text-align:left; cursor:pointer; width:100%; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:12px 14px; box-shadow:var(--shadow-card); display:flex; flex-direction:column; gap:8px; opacity:{{ c.opacity }};">
<sc-if value="{{ c.lpBanner }}" hint-placeholder-val="{{ false }}">
<span style="position:absolute; top:0; left:0; right:0; height:5px; background:var(--accent);"></span>
</sc-if>
<sc-if value="{{ c.lpEarmark }}" hint-placeholder-val="{{ false }}">
<span style="position:absolute; top:0; left:0; width:0; height:0; border-top:18px solid var(--accent); border-right:18px solid transparent;"></span>
</sc-if>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:10px;">
<span style="display:flex; align-items:center; gap:7px; min-width:0;">
<sc-if value="{{ c.lpStar }}" hint-placeholder-val="{{ false }}">
<span style="flex:none; color:var(--accent); font-size:13px; line-height:1;" title="Existing LP"></span>
</sc-if>
<span style="font-size:16px; font-weight:600; color:var(--t1); line-height:1.25; overflow:hidden; text-overflow:ellipsis;">{{ c.name }}</span>
</span>
<sc-if value="{{ c.priority }}" hint-placeholder-val="{{ false }}">
<span style="flex:none; font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase; padding:3px 7px; border-radius:4px; background:{{ priBg }}; color:{{ priText }};">Priority</span>
</sc-if>
</div>
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
<span style="font-family:var(--mono); font-size:15px; font-weight:600; color:{{ c.amtColor }};">{{ c.amount }}</span>
<span style="font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; padding:3px 8px; border-radius:999px; background:{{ c.stageBg }}; color:{{ c.stageText }}; border:1px solid {{ c.stageBorder }};">{{ c.stage }}</span>
</div>
<span style="font-family:var(--mono); font-size:12px; color:{{ c.lastColor }};">{{ c.last }}</span>
</button>
</sc-for>
<sc-if value="{{ listEmpty }}" hint-placeholder-val="{{ false }}">
<div style="padding:48px 20px; text-align:center; color:var(--t4); font-size:14px;">No investors match this view.</div>
</sc-if>
</div>
</div>
</sc-if>
<sc-if value="{{ tabOther }}" hint-placeholder-val="{{ false }}">
<div style="padding:64px 28px; display:flex; flex-direction:column; align-items:center; text-align:center; gap:14px;">
<div style="width:54px; height:54px; border-radius:14px; border:1px solid var(--border); background:var(--panel); display:flex; align-items:center; justify-content:center; color:var(--accent); font-size:24px;">{{ otherIcon }}</div>
<div style="font-size:18px; font-weight:600; color:var(--t1);">{{ otherTitle }}</div>
<div style="font-size:14px; color:var(--t3); line-height:1.5; max-width:240px;">This surface is part of the mobile set — designed next, after the Grid is signed off.</div>
<button onClick="{{ goGrid }}" style="margin-top:6px; height:42px; padding:0 18px; border-radius:8px; border:1px solid var(--bstrong); background:var(--elev); color:var(--t2); font-size:14px; font-weight:500; cursor:pointer;">Back to Grid</button>
</div>
</sc-if>
</div>
<!-- bottom tab bar -->
<div style="flex:none; display:flex; border-top:1px solid var(--border); background:var(--nav-bg); backdrop-filter:blur(8px); padding-bottom:18px;">
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="4">
<button onClick="{{ t.go }}" style="flex:1; background:none; border:none; cursor:pointer; height:56px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:5px; color:{{ t.color }};">
<span style="width:20px; height:20px; display:flex; align-items:center; justify-content:center;">{{ t.icon }}</span>
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.04em;">{{ t.label }}</span>
</button>
</sc-for>
</div>
<!-- account menu -->
<sc-if value="{{ accountMenu }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ closeAccount }}" style="position:absolute; inset:0; z-index:40; animation:fadeIn 120ms ease;">
<div style="position:absolute; top:96px; right:16px; width:208px; background:var(--elev); border:1px solid var(--bstrong); border-radius:10px; box-shadow:0 24px 56px rgba(1,8,17,0.5); overflow:hidden;">
<div style="padding:14px 16px; border-bottom:1px solid var(--border);">
<div style="font-size:14px; font-weight:600; color:var(--t1);">Grant Gilliam</div>
<div style="font-size:12px; color:var(--t3); margin-top:2px;">grant@ten31.xyz</div>
</div>
<div style="padding:6px;">
<div style="padding:11px 12px; border-radius:7px; font-size:14px; color:var(--t2);">Profile</div>
<div style="padding:11px 12px; border-radius:7px; font-size:14px; color:var(--danger);">Log out</div>
</div>
</div>
</div>
</sc-if>
<!-- view picker sheet -->
<sc-if value="{{ viewSheet }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ closeViewSheet }}" style="position:absolute; inset:0; z-index:50; background:rgba(4,9,16,0.55); animation:fadeIn 150ms ease; display:flex; flex-direction:column; justify-content:flex-end;">
<div onClick="{{ stop }}" style="background:var(--panel); border-top:1px solid var(--bstrong); border-radius:20px 20px 0 0; box-shadow:0 -24px 56px rgba(1,8,17,0.4); animation:sheetUp 280ms cubic-bezier(.2,.8,.2,1); padding-bottom:24px; max-height:80%; display:flex; flex-direction:column;">
<div style="padding:10px 0 4px; display:flex; justify-content:center;"><div style="width:38px; height:4px; border-radius:999px; background:var(--bstrong);"></div></div>
<div style="padding:6px 20px 12px; font-size:13px; color:var(--t3); font-weight:500;">Switch view</div>
<div style="overflow-y:auto;">
<sc-for list="{{ viewList }}" as="v" hint-placeholder-count="5">
<button onClick="{{ v.pick }}" style="width:100%; text-align:left; background:none; border:none; cursor:pointer; padding:15px 20px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-top:1px solid var(--divider); color:{{ v.color }};">
<span style="font-size:16px; font-weight:{{ v.weight }};">{{ v.name }}</span>
<span style="display:flex; align-items:center; gap:10px;">
<span style="font-family:var(--mono); font-size:12px; color:var(--t4);">{{ v.count }}</span>
<span style="color:var(--accent); font-size:14px; width:14px;">{{ v.check }}</span>
</span>
</button>
</sc-for>
</div>
</div>
</div>
</sc-if>
<!-- investor detail -->
<sc-if value="{{ detailOpen }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; inset:0; z-index:30; background:radial-gradient(900px 460px at 12% -8%, var(--grad1), transparent 60%), var(--base); display:flex; flex-direction:column; animation:screenIn 200ms ease;">
<div style="flex:none; height:46px;"></div>
<div style="flex:none; display:flex; align-items:center; gap:6px; padding:6px 8px 10px; border-bottom:1px solid var(--border);">
<button onClick="{{ closeDetail }}" style="height:40px; padding:0 10px; background:none; border:none; color:var(--accentlight); font-size:15px; cursor:pointer; display:flex; align-items:center; gap:4px;"> Grid</button>
</div>
<div class="ga-scroll" style="flex:1; min-height:0; overflow-y:auto; padding:18px 16px 32px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px;">
<div style="font-size:22px; font-weight:600; line-height:1.2; color:var(--t1); min-width:0;">{{ inv.name }}</div>
<button onClick="{{ editName }}" style="flex:none; height:34px; padding:0 12px; border-radius:7px; border:1px solid var(--bstrong); background:var(--elev); color:var(--t2); font-size:13px; cursor:pointer;">Edit</button>
</div>
<div style="margin-top:10px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<sc-if value="{{ inv.priority }}" hint-placeholder-val="{{ false }}">
<span style="font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:3px 8px; border-radius:4px; background:{{ priBg }}; color:{{ priText }};">Priority</span>
</sc-if>
<sc-if value="{{ inv.existing }}" hint-placeholder-val="{{ false }}">
<span style="font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:3px 8px; border-radius:4px; background:#3b82c422; color:var(--accentlight);">Existing LP</span>
</sc-if>
<span style="font-family:var(--mono); font-size:12px; color:{{ inv.lastColor }};">Last contact {{ inv.lastText }}</span>
</div>
<div style="margin-top:22px;">
<div style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3); margin-bottom:10px;">Pipeline stage</div>
<button onClick="{{ editStage }}" style="width:100%; text-align:left; cursor:pointer; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px 16px; display:flex; align-items:center; justify-content:space-between; gap:12px; color:var(--t1);">
<span style="display:flex; align-items:center; gap:10px; min-width:0;">
<span style="flex:none; font-family:var(--mono); font-size:13px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; padding:4px 10px; border-radius:999px; background:{{ inv.stageBg }}; color:{{ inv.stageText }}; border:1px solid {{ inv.stageBorder }};">{{ inv.stage }}</span>
<sc-if value="{{ inv.notLinked }}" hint-placeholder-val="{{ false }}"><span style="font-size:12px; color:var(--t4);">not in pipeline yet</span></sc-if>
</span>
<span style="color:var(--t3); font-size:13px; flex:none;">Change </span>
</button>
</div>
<div style="margin-top:22px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
<span style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3);">Contacts</span>
<button onClick="{{ addContact }}" style="background:none; border:none; color:var(--accentlight); font-size:13px; cursor:pointer;">+ Add</button>
</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<sc-for list="{{ inv.contacts }}" as="ct" hint-placeholder-count="1">
<button onClick="{{ ct.edit }}" style="text-align:left; cursor:pointer; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:13px 16px; display:flex; align-items:center; justify-content:space-between; gap:12px; color:var(--t1);">
<span style="display:flex; flex-direction:column; gap:3px; min-width:0;">
<span style="font-size:15px; font-weight:500;">{{ ct.name }}</span>
<span style="font-family:var(--mono); font-size:12px; color:var(--t3); overflow:hidden; text-overflow:ellipsis;">{{ ct.email }}</span>
</span>
<span style="color:var(--t3); font-size:13px; flex:none;"></span>
</button>
</sc-for>
<sc-if value="{{ inv.noContacts }}" hint-placeholder-val="{{ false }}">
<div style="font-size:13px; color:var(--t4); padding:2px 2px 4px;">No contacts yet — add one to enable pipeline linking.</div>
</sc-if>
</div>
</div>
<div style="margin-top:22px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
<span style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3);">Commitments</span>
<span style="font-size:10px; font-family:var(--mono); color:var(--t4); border:1px solid var(--border); border-radius:4px; padding:2px 6px;">read-only</span>
</div>
<div style="background:var(--panel); border:1px solid var(--border); border-radius:10px; overflow:hidden;">
<sc-for list="{{ inv.funds }}" as="f" hint-placeholder-count="3">
<div style="display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-top:1px solid var(--divider);">
<span style="font-size:13px; color:var(--t2);">{{ f.name }}</span>
<span style="font-family:var(--mono); font-size:14px; font-weight:600; color:{{ f.color }};">{{ f.amt }}</span>
</div>
</sc-for>
<div style="display:flex; align-items:center; justify-content:space-between; padding:13px 16px; border-top:1px solid var(--border); background:var(--input);">
<span style="font-family:var(--mono); font-size:11px; letter-spacing:0.06em; text-transform:uppercase; color:var(--t3);">Total invested</span>
<span style="font-family:var(--mono); font-size:15px; font-weight:600; color:var(--money);">{{ inv.total }}</span>
</div>
</div>
</div>
<div style="margin-top:22px;">
<div style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3); margin-bottom:10px;">Reminder</div>
<button onClick="{{ editReminder }}" style="width:100%; text-align:left; cursor:pointer; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px 16px; display:flex; align-items:center; justify-content:space-between; gap:12px; color:var(--t1);">
<sc-if value="{{ inv.hasReminder }}" hint-placeholder-val="{{ false }}">
<span style="display:flex; flex-direction:column; gap:3px;">
<span style="font-size:14px;">{{ inv.reminderNote }}</span>
<span style="font-family:var(--mono); font-size:12px; color:{{ inv.reminderColor }};">Due {{ inv.reminderDate }}</span>
</span>
</sc-if>
<sc-if value="{{ inv.noReminder }}" hint-placeholder-val="{{ true }}">
<span style="font-size:14px; color:var(--t3);">No reminder set</span>
</sc-if>
<span style="color:var(--t3); font-size:13px;"></span>
</button>
</div>
<div style="margin-top:22px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
<span style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3);">Notes / communication</span>
<button onClick="{{ logNote }}" style="background:none; border:none; color:var(--accentlight); font-size:13px; cursor:pointer;">+ Log</button>
</div>
<div style="display:flex; flex-direction:column;">
<sc-for list="{{ inv.notes }}" as="n" hint-placeholder-count="2">
<div style="display:flex; gap:12px; padding-bottom:16px;">
<div style="flex:none; display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="width:9px; height:9px; border-radius:999px; background:var(--accent); margin-top:4px;"></span>
<span style="flex:1; width:1px; background:var(--border);"></span>
</div>
<div style="flex:1; min-width:0;">
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:2px 6px; border-radius:4px; background:{{ n.tagBg }}; color:{{ n.tagText }};">{{ n.type }}</span>
<span style="font-family:var(--mono); font-size:11px; color:var(--t4);">{{ n.date }}</span>
</div>
<div style="font-size:14px; color:var(--t2); margin-top:6px; line-height:1.45;">{{ n.summary }}</div>
</div>
</div>
</sc-for>
<sc-if value="{{ inv.noNotes }}" hint-placeholder-val="{{ false }}">
<div style="font-size:13px; color:var(--t4); padding:4px 0 8px;">No activity logged yet.</div>
</sc-if>
</div>
</div>
</div>
</div>
</sc-if>
<!-- generic edit sheet -->
<sc-if value="{{ sheetOpen }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ closeSheet }}" style="position:absolute; inset:0; z-index:60; background:rgba(4,9,16,0.55); animation:fadeIn 150ms ease; display:flex; flex-direction:column; justify-content:flex-end;">
<div onClick="{{ stop }}" style="background:var(--panel); border-top:1px solid var(--bstrong); border-radius:20px 20px 0 0; box-shadow:0 -24px 56px rgba(1,8,17,0.4); animation:sheetUp 280ms cubic-bezier(.2,.8,.2,1); padding:0 20px 26px; max-height:88%; display:flex; flex-direction:column;">
<div style="padding:10px 0 4px; display:flex; justify-content:center; flex:none;"><div style="width:38px; height:4px; border-radius:999px; background:var(--bstrong);"></div></div>
<div style="display:flex; align-items:center; justify-content:space-between; padding:8px 0 16px; flex:none;">
<span style="font-size:18px; font-weight:600; color:var(--t1);">{{ sheetTitle }}</span>
<button onClick="{{ closeSheet }}" style="background:none; border:none; color:var(--t3); font-size:22px; cursor:pointer; line-height:1; padding:0 4px;">×</button>
</div>
<div class="ga-scroll" style="overflow-y:auto;">
{{ sheetBody }}
</div>
</div>
</div>
</sc-if>
<!-- toast -->
<sc-if value="{{ toast }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; left:16px; right:16px; bottom:92px; z-index:70; background:var(--elev); border:1px solid var(--bstrong); border-radius:10px; box-shadow:0 10px 24px rgba(4,12,22,0.35); padding:13px 16px; font-size:14px; color:var(--t1); display:flex; align-items:center; gap:10px; animation:fadeIn 150ms ease;">
<span style="color:var(--money);"></span>{{ toast }}
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:393,&quot;height&quot;:812},&quot;variant&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;compact&quot;,&quot;roomy&quot;],&quot;default&quot;:&quot;compact&quot;,&quot;tsType&quot;:&quot;'compact'|'roomy'&quot;},&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;dark&quot;,&quot;light&quot;],&quot;default&quot;:&quot;dark&quot;,&quot;tsType&quot;:&quot;'dark'|'light'&quot;},&quot;font&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;plex&quot;,&quot;manrope&quot;,&quot;hanken&quot;],&quot;default&quot;:&quot;plex&quot;,&quot;tsType&quot;:&quot;'plex'|'manrope'|'hanken'&quot;},&quot;lpFlag&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;star&quot;,&quot;earmark&quot;,&quot;banner&quot;],&quot;default&quot;:&quot;earmark&quot;,&quot;tsType&quot;:&quot;'star'|'earmark'|'banner'&quot;}}">
class Component extends DCLogic {
constructor(props) {
super(props);
const focus = window.T31Store ? window.T31Store.focusInvestorId : null;
if (window.T31Store) window.T31Store.focusInvestorId = null;
this.state = {
theme: props.theme === 'light' ? 'light' : 'dark',
tab: 'grid',
view: 'Main Fundraising',
search: '',
viewSheet: false,
accountMenu: false,
sortKey: 'name',
detailId: focus || null,
sheet: null,
toast: null,
investors: this.seed(),
};
}
componentDidMount() { if (window.T31Store) this._unsub = window.T31Store.subscribe(() => this.forceUpdate()); }
componentWillUnmount() { if (this._unsub) this._unsub(); }
seed() {
const C = (name, email) => ({ name, email });
// daysAgo derives from server last_activity_at; priority is a disposition flag.
return [
{ id: 1, name: 'Northwall Capital', priority: true, stage: 'commitment', daysAgo: 2,
contacts: [C('Dana Reyes', 'dana@northwall.com'), C('Per Holt', 'per@northwall.com')],
funds: [['Ten31 Terahash', 1500000], ['Sats and Stats', 600000], ['Join the Fold', 400000]],
reminder: { date: 'Jun 24', note: 'Send Q2 update deck' }, views: ['Main Fundraising', 'All Investors'],
notes: [ ['Email', 'Confirmed $2.5M allocation across funds', '2026-06-17'], ['Meeting', 'DD call — covered redemption terms', '2026-06-10'] ] },
{ id: 2, name: 'Brightseed Partners', priority: true, stage: 'engaged', daysAgo: 5,
contacts: [C('Omar Said', 'omar@brightseed.vc')], funds: [['Ten31 Terahash', 0]],
reminder: { date: 'Jun 20', note: 'Follow up after intro call' }, views: ['Main Fundraising', 'Follow-up List'],
notes: [ ['Note', 'Intro from Polaris — warm', '2026-06-14'] ] },
{ id: 3, name: 'Cedarline Family Office', priority: false, stage: 'commitment', daysAgo: 7,
contacts: [C('Lena Cho', 'lena@cedarline.com')], funds: [['Ten31 Terahash', 800000], ['Pawn to F4', 400000]],
reminder: null, views: ['Main Fundraising', 'All Investors', 'Fund II investors'],
notes: [ ['Call', 'Wire received, fully funded', '2026-06-12'] ] },
{ id: 4, name: 'Vance & Co', priority: false, stage: 'engaged', daysAgo: 3,
contacts: [C('Marcus Vance', 'mv@vanceco.com')], funds: [['Ten31 Terahash', 0]],
reminder: { date: 'Jun 19', note: 'Resend deck — bounced' }, views: ['Main Fundraising', 'Follow-up List'],
notes: [] },
{ id: 5, name: 'Polaris Endowment', priority: true, stage: 'diligence', daysAgo: 1,
contacts: [C('Ruth Almeida', 'ralmeida@polaris.org')], funds: [['Ten31 Terahash', 3000000], ['Sats and Stats', 2000000]],
reminder: { date: 'Jun 21', note: 'IC memo due' }, views: ['Main Fundraising', 'All Investors', 'Follow-up List', 'Fund II investors'],
notes: [ ['Meeting', 'IC presentation went well', '2026-06-18'], ['Email', 'Sent data room access', '2026-06-15'] ] },
{ id: 6, name: 'Hartman Group', priority: false, stage: null, daysAgo: 14,
contacts: [], funds: [['Ten31 Terahash', 0]],
reminder: null, views: ['Main Fundraising'], notes: [] },
{ id: 7, name: 'Meridian Trust', priority: false, stage: 'commitment', daysAgo: 4,
contacts: [C('Sofia Marin', 'sofia@meridiantrust.com')], funds: [['Ten31 Terahash', 800000]],
reminder: null, views: ['Main Fundraising', 'All Investors'],
notes: [ ['Note', 'Signed side letter', '2026-06-14'] ] },
{ id: 8, name: 'Atlas Ventures Fund', priority: false, stage: 'engaged', daysAgo: 6,
contacts: [C('Will Tanaka', 'will@atlasvf.com')], funds: [['Ten31 Terahash', 0]],
reminder: null, views: ['Main Fundraising'], notes: [] },
{ id: 9, name: 'K. Whitfield', priority: false, stage: null, daysAgo: 21,
contacts: [C('Kira Whitfield', 'kira@whitfield.io')], funds: [],
reminder: null, views: ['Graveyard'], notes: [ ['Note', 'No allocation — parked', '2026-05-28'] ] },
{ id: 10, name: 'Granite Bay LP', priority: false, stage: 'commitment', daysAgo: 30,
contacts: [C('Tom Becker', 'tom@granitebay.com')], funds: [['Ten31 Terahash', 2000000], ['Sats and Stats', 1300000]],
reminder: null, views: ['Main Fundraising', 'All Investors', 'Fund II investors'], notes: [] },
{ id: 11, name: 'Forsythe Holdings', priority: false, stage: 'lead', daysAgo: 35,
contacts: [], funds: [], reminder: null, views: ['Graveyard'], notes: [] },
];
}
themePalette(theme) {
if (theme === 'light') return {
base: '#eaeef3', panel: '#ffffff', elev: '#f4f7fb', input: '#eef2f7', hover: '#e6ecf4',
border: '#d6dde7', bstrong: '#b6c3d4', divider: '#e8edf3',
t1: '#16202c', t2: '#33414f', t3: '#5a6b7d', t4: '#84909e', accentlight: '#1f6fb8', danger: '#c0322f', money: '#057a55' };
return { base: '#0b1118', panel: '#111a27', elev: '#152233', input: '#0d1622', hover: '#1b2a3a',
border: '#263548', bstrong: '#35506a', divider: '#1c2735',
t1: '#e5edf5', t2: '#c7d3e0', t3: '#8ea2b7', t4: '#70859b', accentlight: '#93c5fd', danger: '#e06c6c', money: '#6ee7b7' };
}
priColors(theme) {
return theme === 'light' ? { bg: '#e08e0922', text: '#a76a07' } : { bg: '#f59e0b22', text: '#fcd34d' };
}
stageColors(s, theme) {
const light = theme === 'light';
const dark = {
'lead': { bg: '#70859b22', text: '#8ea2b7', border: '#2635488a' },
'engaged': { bg: '#3b82c422', text: '#93c5fd', border: '#3b82c44d' },
'diligence': { bg: '#e0b3411f', text: '#e0b341', border: '#e0b3413d' },
'commitment': { bg: '#10b9811f', text: '#6ee7b7', border: '#10b9813d' },
};
const lite = {
'lead': { bg: '#5a6b7d14', text: '#5a6b7d', border: '#d6dde7' },
'engaged': { bg: '#3b82c416', text: '#2266a0', border: '#bcd2ea' },
'diligence': { bg: '#e0b34122', text: '#8a6c12', border: '#e4d29a' },
'commitment': { bg: '#10b98118', text: '#057a55', border: '#a9ddca' },
};
const map = light ? lite : dark;
return map[s] || (light ? { bg: '#5a6b7d12', text: '#84909e', border: '#d6dde7' } : { bg: '#1b2a3a', text: '#70859b', border: '#263548' });
}
// Staleness from one global threshold on days-since-last-activity. Thresholds TBD with team.
recency(days, theme) {
const AMBER = 10, STALE = 30;
const light = theme === 'light';
if (days >= STALE) return { text: days + 'd stale', color: light ? '#c0322f' : '#f87171' };
if (days >= AMBER) return { text: days + 'd ago', color: light ? '#a76a07' : '#e0b341' };
return { text: days + 'd ago', color: light ? '#84909e' : '#70859b' };
}
noteTag(t, theme) {
const light = theme === 'light';
const dark = { 'Email': { bg: '#3b82c422', text: '#93c5fd' }, 'Call': { bg: '#10b98122', text: '#6ee7b7' },
'Meeting': { bg: '#f59e0b1f', text: '#fcd34d' }, 'Note': { bg: '#1b2a3a', text: '#8ea2b7' } };
const lite = { 'Email': { bg: '#3b82c41a', text: '#2266a0' }, 'Call': { bg: '#10b9811a', text: '#057a55' },
'Meeting': { bg: '#f59e0b1a', text: '#a76a07' }, 'Note': { bg: '#5a6b7d14', text: '#5a6b7d' } };
const map = light ? lite : dark;
return map[t] || map['Note'];
}
dueColor(iso, theme) {
const S = window.T31Store; const days = S ? S.diffDays(iso) : 99;
if (days < 0) return theme === 'light' ? '#c0322f' : '#f87171';
if (days <= 1) return theme === 'light' ? '#8a6c12' : '#e0b341';
return theme === 'light' ? '#5a6b7d' : '#8ea2b7';
}
money(n) {
if (!n) return '$0';
if (n >= 1e6) return '$' + (n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1) + 'M';
if (n >= 1e3) return '$' + Math.round(n / 1e3) + 'K';
return '$' + n;
}
committed(inv) { return (inv.funds || []).reduce((a, f) => a + f[1], 0); }
stageLabel(s) { return s || 'no stage'; }
viewDefs() { return ['Main Fundraising', 'Follow-up List', 'Graveyard', 'All Investors', 'Fund II investors']; }
inView(inv, view) { return (inv.views || []).includes(view); }
toast(msg) { this.setState({ toast: msg }); clearTimeout(this._tt); this._tt = setTimeout(() => this.setState({ toast: null }), 2200); }
sortList(arr, key) {
const order = ['lead', 'engaged', 'diligence', 'commitment'];
const a = arr.slice();
if (key === 'stage') a.sort((x, y) => { const xi = x.stage ? order.indexOf(x.stage) : 99, yi = y.stage ? order.indexOf(y.stage) : 99; return xi - yi || x.name.localeCompare(y.name); });
else if (key === 'amount') a.sort((x, y) => this.committed(y) - this.committed(x) || x.name.localeCompare(y.name));
else if (key === 'staleness') a.sort((x, y) => y.daysAgo - x.daysAgo || x.name.localeCompare(y.name));
else if (key === 'priority') a.sort((x, y) => (y.priority ? 1 : 0) - (x.priority ? 1 : 0) || x.name.localeCompare(y.name));
else a.sort((x, y) => x.name.localeCompare(y.name));
return a;
}
sortLabelFor(key) { return ({ name: 'Name', stage: 'Stage', amount: 'Amount', staleness: 'Staleness', priority: 'Priority' })[key] || 'Name'; }
updateInv(id, patch) { if (window.T31Store) window.T31Store.updateInvestor(id, patch); }
selectedInv() { return window.T31Store ? window.T31Store.investorById(this.state.detailId) : null; }
renderVals() {
const s = this.state;
const theme = s.theme;
const q = s.search.trim().toLowerCase();
const all = window.T31Store ? window.T31Store.investors : [];
const list = all.filter(i => this.inView(i, s.view)).filter(i => {
if (!q) return true;
return i.name.toLowerCase().includes(q) || (i.contacts || []).some(c => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q));
});
const moneyColor = theme === 'light' ? '#057a55' : '#6ee7b7';
const pri = this.priColors(theme);
const dimmed = s.view === 'Graveyard';
const lpFlag = this.props.lpFlag || 'earmark';
const cards = this.sortList(list, s.sortKey).map(i => {
const sc = this.stageColors(i.stage, theme);
const amt = this.committed(i);
const rec = this.recency(i.daysAgo, theme);
const existing = amt > 0;
return {
name: i.name,
existing: existing, priority: !!i.priority,
lpStar: existing && lpFlag === 'star',
lpEarmark: existing && lpFlag === 'earmark',
lpBanner: existing && lpFlag === 'banner',
amount: this.money(amt), amtColor: amt > 0 ? moneyColor : (theme === 'light' ? '#84909e' : '#70859b'),
stage: this.stageLabel(i.stage), stageBg: sc.bg, stageText: sc.text, stageBorder: sc.border,
last: rec.text, lastColor: rec.color,
opacity: dimmed ? '0.55' : '1',
open: () => this.setState({ detailId: i.id }),
};
});
const viewList = this.viewDefs().map(name => ({
name, count: String(all.filter(i => this.inView(i, name)).length),
color: name === s.view ? 'var(--t1)' : 'var(--t2)',
weight: name === s.view ? 600 : 400,
check: name === s.view ? '✓' : '',
pick: () => this.setState({ view: name, viewSheet: false }),
}));
const tabs = [
{ key: 'grid', label: 'Grid' }, { key: 'pipeline', label: 'Pipeline' },
{ key: 'reminders', label: 'Reminders' }, { key: 'contacts', label: 'Contacts' },
].map(t => ({
label: t.label, color: t.key === 'grid' ? 'var(--accent)' : 'var(--t4)',
icon: this.tabIcon(t.key, t.key === 'grid'),
go: () => { if (window.T31Store) window.T31Store.setTab(t.key); },
}));
const sel = this.selectedInv();
let inv = null;
if (sel) {
const sc = this.stageColors(sel.stage, theme);
const selAmt = this.committed(sel);
const selRec = this.recency(sel.daysAgo, theme);
const rem = window.T31Store ? window.T31Store.reminderFor(sel.id) : null;
inv = {
name: sel.name, existing: selAmt > 0, priority: !!sel.priority,
lastText: selRec.text, lastColor: selRec.color,
stage: this.stageLabel(sel.stage), stageBg: sc.bg, stageText: sc.text, stageBorder: sc.border,
notLinked: !sel.stage,
contacts: sel.contacts.map((c, idx) => ({ name: c.name, email: c.email || 'no email', edit: () => this.openSheet('contact', { idx, name: c.name, email: c.email }) })),
noContacts: sel.contacts.length === 0,
funds: (sel.funds.length ? sel.funds : [['Ten31 Terahash', 0]]).map(f => ({ name: f[0], amt: this.money(f[1]), color: f[1] > 0 ? moneyColor : (theme === 'light' ? '#84909e' : '#70859b') })),
total: this.money(this.committed(sel)),
hasReminder: !!rem, noReminder: !rem,
reminderNote: rem ? rem.note : '', reminderDate: rem ? window.T31Store.monthDay(rem.due) : '',
reminderColor: rem ? this.dueColor(rem.due, theme) : 'var(--t3)',
notes: sel.notes.map(n => { const nt = this.noteTag(n[0], theme); return { type: n[0].toUpperCase(), tagBg: nt.bg, tagText: nt.text, date: n[2], summary: n[1] }; }),
noNotes: sel.notes.length === 0,
};
}
const sheetBody = s.sheet ? this.buildSheet(s.sheet) : null;
const tabOther = s.tab !== 'grid';
const otherMeta = { pipeline: ['◧', 'Pipeline'], reminders: ['◷', 'Reminders'], contacts: ['◓', 'Contacts'] }[s.tab] || ['', ''];
return {
themeAttr: theme, themeIcon: theme === 'light' ? '☾' : '☀',
fontAttr: this.props.font || 'plex',
priBg: pri.bg, priText: pri.text,
toggleTheme: () => { const t = s.theme === 'light' ? 'dark' : 'light'; if (window.T31Store) window.T31Store.setTheme(t); this.setState({ theme: t }); },
view: s.view,
listCountLabel: `${list.length} ${list.length === 1 ? 'investor' : 'investors'}`,
search: s.search,
onSearch: e => this.setState({ search: e.target.value }),
openViewSheet: () => this.setState({ viewSheet: true }),
closeViewSheet: () => this.setState({ viewSheet: false }),
viewSheet: s.viewSheet, viewList,
toggleAccount: () => this.setState({ accountMenu: !s.accountMenu }),
closeAccount: () => this.setState({ accountMenu: false }),
accountMenu: s.accountMenu,
openCreate: () => this.openSheet('create', { name: '', cname: '', cemail: '', priority: false, stage: 'lead' }),
openSortSheet: () => this.openSheet('sort', {}),
sortLabel: this.sortLabelFor(s.sortKey),
openQuickLog: () => this.openSheet('quicklog', { q: '', targetId: null, type: 'Note', summary: '', details: '' }),
tabs, tabGrid: true, tabOther: false,
otherIcon: otherMeta[0], otherTitle: otherMeta[1],
goGrid: () => { if (window.T31Store) window.T31Store.setTab('grid'); },
cards, listEmpty: cards.length === 0,
detailOpen: !!sel, inv,
closeDetail: () => this.setState({ detailId: null }),
editName: () => this.openSheet('name', { name: sel.name }),
editStage: () => this.openSheet('stage', { stage: sel.stage, linked: !!sel.stage }),
addContact: () => this.openSheet('contact', { idx: -1, name: '', email: '' }),
editReminder: () => { const rm = window.T31Store ? window.T31Store.reminderFor(sel.id) : null; this.openSheet('reminder', { rid: rm ? rm.id : null, date: rm ? rm.due : '', note: rm ? rm.note : '' }); },
logNote: () => this.openSheet('note', { type: 'Note', summary: '', details: '' }),
sheetOpen: !!s.sheet, sheetTitle: s.sheet ? s.sheet._title : '', sheetBody,
closeSheet: () => this.setState({ sheet: null }),
stop: e => e.stopPropagation(),
toast: s.toast,
};
}
tabIcon(key, active) {
const c = active ? '#3b82c4' : (this.state.theme === 'light' ? '#84909e' : '#70859b');
const mk = (children) => React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 20 20', fill: 'none' }, children);
const r = (p) => React.createElement('rect', p);
const ln = (p) => React.createElement('line', Object.assign({}, p, { stroke: c, strokeWidth: 1.6, strokeLinecap: 'round' }));
if (key === 'grid') return mk([
r({ key: 1, x: 3, y: 3, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
r({ key: 2, x: 11, y: 3, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
r({ key: 3, x: 3, y: 11, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
r({ key: 4, x: 11, y: 11, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
]);
if (key === 'pipeline') return mk([
r({ key: 1, x: 3, y: 3, width: 4.5, height: 14, rx: 1, stroke: c, strokeWidth: 1.6 }),
r({ key: 2, x: 9.25, y: 3, width: 4.5, height: 10, rx: 1, stroke: c, strokeWidth: 1.6 }),
r({ key: 3, x: 15.5, y: 3, width: 1.5, height: 6, rx: 0.7, fill: c }),
]);
if (key === 'reminders') return mk([
React.createElement('circle', { key: 1, cx: 10, cy: 11, r: 6.2, stroke: c, strokeWidth: 1.6 }),
ln({ key: 2, x1: 10, y1: 11, x2: 10, y2: 7.5 }),
ln({ key: 3, x1: 10, y1: 11, x2: 12.4, y2: 12 }),
ln({ key: 4, x1: 7, y1: 3.4, x2: 4.4, y2: 5.4 }),
ln({ key: 5, x1: 13, y1: 3.4, x2: 15.6, y2: 5.4 }),
]);
return mk([
React.createElement('circle', { key: 1, cx: 10, cy: 7, r: 3.2, stroke: c, strokeWidth: 1.6 }),
React.createElement('path', { key: 2, d: 'M4 16.5c0-3 2.7-4.8 6-4.8s6 1.8 6 4.8', stroke: c, strokeWidth: 1.6, strokeLinecap: 'round' }),
]);
}
openSheet(kind, draft) {
const titles = { name: 'Edit investor name', contact: draft.idx === -1 ? 'Add contact' : 'Edit contact',
note: 'Log communication', stage: 'Pipeline stage', reminder: 'Set reminder', create: 'New investor', quicklog: 'Log communication', sort: 'Sort investors' };
this.setState({ sheet: Object.assign({ kind: kind, _title: titles[kind] }, draft) });
}
setDraft(patch) { this.setState(s => ({ sheet: Object.assign({}, s.sheet, patch) })); }
buildSheet(sh) {
const h = React.createElement;
const sel = this.selectedInv();
const p = this.themePalette(this.state.theme);
const theme = this.state.theme;
const label = (t) => h('div', { style: { fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '0.08em', textTransform: 'uppercase', color: p.t3, margin: '14px 0 8px' } }, t);
const inputStyle = { width: '100%', height: 46, background: p.input, border: '1px solid ' + p.border, borderRadius: 8, color: p.t1, fontFamily: 'var(--sans)', fontSize: 15, padding: '0 14px', outline: 'none', boxSizing: 'border-box' };
const areaStyle = Object.assign({}, inputStyle, { height: 96, padding: '12px 14px', resize: 'none', lineHeight: 1.45 });
const help = (t) => h('div', { style: { fontSize: 12, color: p.t4, marginTop: 7, lineHeight: 1.45 } }, t);
const primaryBtn = (txt, onClick, disabled) => h('button', { onClick, disabled, style: { width: '100%', height: 48, marginTop: 22, borderRadius: 8, border: 'none', background: disabled ? p.elev : 'linear-gradient(#3b82c4,#2f6ea9)', color: disabled ? p.t4 : '#fff', fontSize: 15, fontWeight: 600, cursor: disabled ? 'default' : 'pointer', fontFamily: 'var(--sans)', boxShadow: disabled ? 'none' : '0 6px 14px rgba(12,40,68,0.35)' } }, txt);
if (sh.kind === 'sort') {
const opts = [['name', 'Name', 'A → Z'], ['stage', 'Pipeline stage', 'Lead → Commitment'], ['amount', 'Committed', 'Most first'], ['staleness', 'Last contact', 'Most stale first'], ['priority', 'Priority', 'Flagged first']];
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } }, opts.map(o => {
const on = this.state.sortKey === o[0];
return h('button', { key: o[0], onClick: () => this.setState({ sortKey: o[0], sheet: null }), style: { width: '100%', textAlign: 'left', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, minHeight: 52, padding: '0 15px', borderRadius: 10, border: '1px solid ' + (on ? p.bstrong : p.border), background: on ? p.elev : p.input } },
h('span', { style: { display: 'flex', flexDirection: 'column', gap: 2 } },
h('span', { style: { fontSize: 15, fontWeight: 500, color: p.t1 } }, o[1]),
h('span', { style: { fontFamily: 'var(--mono)', fontSize: 11, color: p.t4 } }, o[2])),
on ? h('span', { style: { color: 'var(--accent)', fontSize: 15 } }, '\u2713') : null);
}));
}
if (sh.kind === 'name') {
return h('div', null,
label('Investor name'),
h('input', { value: sh.name, onChange: e => this.setDraft({ name: e.target.value }), style: inputStyle, autoFocus: true }),
help('Writes a single-row update — no full-grid save, no version race.'),
primaryBtn('Save name', () => { this.updateInv(sel.id, { name: sh.name }); this.setState({ sheet: null }); this.toast('Investor name updated'); }, !sh.name.trim())
);
}
if (sh.kind === 'contact') {
const isNew = sh.idx === -1;
return h('div', null,
label('Name'),
h('input', { value: sh.name, onChange: e => this.setDraft({ name: e.target.value }), style: inputStyle, placeholder: 'Contact name', autoFocus: true }),
label('Email'),
h('input', { value: sh.email, onChange: e => this.setDraft({ email: e.target.value }), style: Object.assign({}, inputStyle, { fontFamily: 'var(--mono)', fontSize: 14 }), placeholder: 'name@firm.com', inputMode: 'email' }),
help(isNew ? 'Adds a contact pill to this investor row.' : 'Editing the contact pill. Removing a pill has no undo — the grid blob is canonical.'),
h('div', { style: { display: 'flex', gap: 10, marginTop: 22 } },
!isNew ? h('button', { onClick: () => { const cs = sel.contacts.filter((_, i) => i !== sh.idx); this.updateInv(sel.id, { contacts: cs }); this.setState({ sheet: null }); this.toast('Contact removed'); }, style: { height: 48, padding: '0 16px', borderRadius: 8, border: '1px solid ' + p.danger, background: 'transparent', color: p.danger, fontSize: 14, fontWeight: 500, cursor: 'pointer', flex: 'none' } }, 'Remove') : null,
h('button', { onClick: () => {
let cs = sel.contacts.slice();
if (isNew) cs.push({ name: sh.name, email: sh.email });
else cs[sh.idx] = { name: sh.name, email: sh.email };
this.updateInv(sel.id, { contacts: cs }); this.setState({ sheet: null }); this.toast(isNew ? 'Contact added' : 'Contact updated');
}, disabled: !sh.name.trim(), style: { flex: 1, height: 48, borderRadius: 8, border: 'none', background: !sh.name.trim() ? p.elev : 'linear-gradient(#3b82c4,#2f6ea9)', color: !sh.name.trim() ? p.t4 : '#fff', fontSize: 15, fontWeight: 600, cursor: 'pointer' } }, isNew ? 'Add contact' : 'Save')
)
);
}
if (sh.kind === 'note') {
const types = ['Note', 'Email', 'Call', 'Meeting'];
return h('div', null,
label('Type'),
h('div', { style: { display: 'flex', gap: 8 } }, types.map(t => {
const on = sh.type === t; const tc = this.noteTag(t, theme);
return h('button', { key: t, onClick: () => this.setDraft({ type: t }), style: { flex: 1, height: 40, borderRadius: 7, cursor: 'pointer', fontFamily: 'var(--mono)', fontSize: 12, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', border: '1px solid ' + (on ? p.bstrong : p.border), background: on ? tc.bg : p.input, color: on ? tc.text : p.t3 } }, t);
})),
label('Summary'),
h('input', { value: sh.summary, onChange: e => this.setDraft({ summary: e.target.value }), style: inputStyle, placeholder: 'Short headline', autoFocus: true }),
label('Details'),
h('textarea', { value: sh.details, onChange: e => this.setDraft({ details: e.target.value }), style: areaStyle, placeholder: 'Full context kept in communications history' }),
help('Posts immediately to the shared timeline via the one-row log path.'),
primaryBtn('Log communication', () => {
const today = '2026-06-19';
if (window.T31Store) window.T31Store.logNote(sel.id, [sh.type, sh.summary, today]);
this.setState({ sheet: null }); this.toast('Communication logged');
}, !sh.summary.trim())
);
}
if (sh.kind === 'stage') {
const stages = ['lead', 'engaged', 'diligence', 'commitment'];
const noContacts = sel.contacts.length === 0;
if (!sh.linked) {
return h('div', null,
h('div', { style: { fontSize: 14, color: p.t2, lineHeight: 1.5, marginTop: 6 } }, 'This investor isn\u2019t in the pipeline yet. Add them to create a pipeline opportunity, then set a stage.'),
noContacts ? h('div', { style: { marginTop: 14, padding: '12px 14px', borderRadius: 8, border: '1px solid ' + (theme === 'light' ? '#e4d29a' : '#e0b3413d'), background: theme === 'light' ? '#f59e0b14' : '#e0b3411a', fontSize: 13, color: theme === 'light' ? '#8a6c12' : '#e0b341', lineHeight: 1.45 } }, 'Needs at least one contact before it can be linked to the pipeline.') : null,
primaryBtn('Add to pipeline', () => { this.setDraft({ linked: true, stage: 'lead' }); }, noContacts)
);
}
return h('div', null,
label('Stage'),
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } }, stages.map(st => {
const on = sh.stage === st; const sc = this.stageColors(st, theme);
return h('button', { key: st, onClick: () => this.setDraft({ stage: st }), style: { width: '100%', height: 48, borderRadius: 8, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px', border: '1px solid ' + (on ? p.bstrong : p.border), background: on ? p.elev : p.input } },
h('span', { style: { fontFamily: 'var(--mono)', fontSize: 13, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', padding: '4px 10px', borderRadius: 999, background: sc.bg, color: sc.text, border: '1px solid ' + sc.border } }, st),
on ? h('span', { style: { color: '#3b82c4', fontSize: 15 } }, '\u2713') : null
);
})),
help('Shares the opportunities endpoint with the Pipeline tab.'),
primaryBtn('Update stage', () => { this.updateInv(sel.id, { stage: sh.stage }); this.setState({ sheet: null }); this.toast('Pipeline stage updated'); })
);
}
if (sh.kind === 'reminder') {
const S = window.T31Store;
const presets = [['Tomorrow', '2026-06-20'], ['In 3 days', '2026-06-22'], ['Next week', '2026-06-26'], ['In 2 weeks', '2026-07-03']];
return h('div', null,
label('Due date'),
h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, presets.map(d => {
const on = sh.date === d[1];
return h('button', { key: d[1], onClick: () => this.setDraft({ date: d[1] }), style: { flex: '1 0 40%', height: 42, borderRadius: 7, cursor: 'pointer', fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 500, border: '1px solid ' + (on ? p.bstrong : p.border), background: on ? p.elev : p.input, color: on ? p.t1 : p.t3 } }, d[0] + ' · ' + (S ? S.monthDay(d[1]) : ''));
})),
label('Note'),
h('input', { value: sh.note, onChange: e => this.setDraft({ note: e.target.value }), style: inputStyle, placeholder: 'What needs doing?', autoFocus: true }),
help('Saved to Reminders and shown on the investor row.'),
h('div', { style: { display: 'flex', gap: 10, marginTop: 22 } },
sh.rid ? h('button', { onClick: () => { if (S) S.deleteReminder(sh.rid); this.setState({ sheet: null }); this.toast('Reminder cleared'); }, style: { height: 48, padding: '0 16px', borderRadius: 8, border: '1px solid ' + p.bstrong, background: p.elev, color: p.t2, fontSize: 14, cursor: 'pointer', flex: 'none' } }, 'Clear') : null,
h('button', { onClick: () => { const due = sh.date || '2026-06-22'; if (S) { if (sh.rid) S.updateReminder(sh.rid, { note: sh.note, due: due, done: false }); else S.addReminder(sel.id, sh.note || 'Follow up', due); } this.setState({ sheet: null }); this.toast('Reminder set'); }, disabled: !sh.note.trim(), style: { flex: 1, height: 48, borderRadius: 8, border: 'none', background: !sh.note.trim() ? p.elev : 'linear-gradient(#3b82c4,#2f6ea9)', color: !sh.note.trim() ? p.t4 : '#fff', fontSize: 15, fontWeight: 600, cursor: 'pointer' } }, 'Save reminder')
)
);
}
if (sh.kind === 'create') {
const qn = sh.name.trim().toLowerCase();
const matches = qn.length >= 2 ? (window.T31Store ? window.T31Store.investors : []).filter(i => i.name.toLowerCase().includes(qn)).slice(0, 3) : [];
const stages = ['lead', 'engaged', 'diligence', 'commitment'];
const warnBorder = theme === 'light' ? '#e4d29a' : '#e0b3413d';
const warnBg = theme === 'light' ? '#f59e0b12' : '#e0b3411a';
const warnText = theme === 'light' ? '#8a6c12' : '#e0b341';
const prc = this.priColors(theme);
return h('div', null,
label('Investor name'),
h('input', { value: sh.name, onChange: e => this.setDraft({ name: e.target.value }), style: inputStyle, placeholder: 'Search or create…', autoFocus: true }),
matches.length ? h('div', { style: { marginTop: 10, border: '1px solid ' + warnBorder, background: warnBg, borderRadius: 8, overflow: 'hidden' } }, [
h('div', { key: 'h', style: { padding: '9px 13px', fontSize: 12, color: warnText, borderBottom: '1px solid ' + warnBorder } }, 'Possible existing match — open instead of creating a duplicate?')
].concat(matches.map(m => { const ms = this.stageColors(m.stage, theme); return h('button', { key: m.id, onClick: () => { this.setState({ sheet: null, detailId: m.id, tab: 'grid' }); }, style: { width: '100%', textAlign: 'left', padding: '11px 13px', background: 'none', border: 'none', borderTop: '1px solid ' + warnBorder, cursor: 'pointer', color: p.t1, fontSize: 14, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 } }, h('span', null, m.name), h('span', { style: { flex: 'none', fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', padding: '3px 8px', borderRadius: 999, background: ms.bg, color: ms.text, border: '1px solid ' + ms.border } }, this.stageLabel(m.stage))); }))) : null,
label('First contact'),
h('input', { value: sh.cname, onChange: e => this.setDraft({ cname: e.target.value }), style: inputStyle, placeholder: 'Contact name' }),
h('input', { value: sh.cemail, onChange: e => this.setDraft({ cemail: e.target.value }), style: Object.assign({}, inputStyle, { marginTop: 8, fontFamily: 'var(--mono)', fontSize: 14 }), placeholder: 'name@firm.com', inputMode: 'email' }),
label('Initial stage'),
h('div', { style: { display: 'flex', gap: 8 } }, stages.map(t => {
const on = sh.stage === t; const sc = this.stageColors(t, theme);
return h('button', { key: t, onClick: () => this.setDraft({ stage: t }), style: { flex: 1, height: 44, borderRadius: 7, cursor: 'pointer', fontFamily: 'var(--mono)', fontSize: 11, fontWeight: 600, letterSpacing: '0.03em', textTransform: 'uppercase', border: '1px solid ' + (on ? p.bstrong : p.border), background: on ? sc.bg : p.input, color: on ? sc.text : p.t3, lineHeight: 1.1, textAlign: 'center', padding: '0 4px' } }, t);
})),
label('Disposition'),
h('button', { onClick: () => this.setDraft({ priority: !sh.priority }), style: { width: '100%', height: 48, borderRadius: 8, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 14px', border: '1px solid ' + (sh.priority ? p.bstrong : p.border), background: sh.priority ? prc.bg : p.input } },
h('span', { style: { fontSize: 14, color: sh.priority ? prc.text : p.t2, fontWeight: 500 } }, 'Flag as Priority'),
h('span', { style: { width: 40, height: 24, borderRadius: 999, background: sh.priority ? '#3b82c4' : p.bstrong, position: 'relative', transition: 'background 150ms', flex: 'none' } },
h('span', { style: { position: 'absolute', top: 3, left: sh.priority ? 19 : 3, width: 18, height: 18, borderRadius: 999, background: '#fff', transition: 'left 150ms' } }))
),
help('Creates the row + first contact in one call (create_investor_if_missing). Commitments and the full column set are filled later on desktop.'),
primaryBtn('Create investor', () => {
const contacts = sh.cname.trim() ? [{ name: sh.cname, email: sh.cemail }] : [];
const ni = { name: sh.name.trim(), priority: !!sh.priority, stage: sh.stage, daysAgo: 0, contacts: contacts, funds: [['Ten31 Terahash', 0]], views: ['Main Fundraising'], notes: [] };
const id = window.T31Store ? window.T31Store.addInvestor(ni) : 0;
this.setState({ sheet: null, view: 'Main Fundraising', detailId: id });
this.toast('Investor created');
}, !sh.name.trim())
);
}
if (sh.kind === 'quicklog') {
const qn = (sh.q || '').trim().toLowerCase();
if (!sh.targetId) {
let pool = (window.T31Store ? window.T31Store.investors : []).slice();
if (qn) pool = pool.filter(i => i.name.toLowerCase().includes(qn) || (i.contacts || []).some(c => c.name.toLowerCase().includes(qn) || (c.email || '').toLowerCase().includes(qn)));
else pool = pool.sort((a, b) => a.daysAgo - b.daysAgo);
pool = pool.slice(0, 8);
return h('div', null,
h('div', { style: { fontSize: 13, color: p.t3, lineHeight: 1.5, margin: '0 0 12px' } }, 'Pick an investor or contact, then log the communication.'),
h('input', { value: sh.q, onChange: e => this.setDraft({ q: e.target.value }), style: inputStyle, placeholder: 'Search investor or contact…', autoFocus: true }),
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 } }, pool.length ? pool.map(i => {
const sc = this.stageColors(i.stage, theme); const amt = this.committed(i);
const sub = i.contacts[0] ? i.contacts[0].name + (i.contacts.length > 1 ? ' +' + (i.contacts.length - 1) : '') : 'No contacts';
return h('button', { key: i.id, onClick: () => this.setDraft({ targetId: i.id }), style: { width: '100%', textAlign: 'left', cursor: 'pointer', background: p.input, border: '1px solid ' + p.border, borderRadius: 10, padding: '11px 13px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, color: p.t1 } },
h('span', { style: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 } },
h('span', { style: { fontSize: 15, fontWeight: 500 } }, (amt > 0 ? '★ ' : '') + i.name),
h('span', { style: { fontFamily: 'var(--mono)', fontSize: 12, color: p.t3 } }, sub)),
h('span', { style: { flex: 'none', fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', padding: '3px 8px', borderRadius: 999, background: sc.bg, color: sc.text, border: '1px solid ' + sc.border } }, this.stageLabel(i.stage)));
}) : h('div', { style: { fontSize: 13, color: p.t4, padding: '16px 4px' } }, 'No matches.'))
);
}
const t = (window.T31Store ? window.T31Store.investors : []).find(i => i.id === sh.targetId);
const types = ['Note', 'Email', 'Call', 'Meeting'];
return h('div', null,
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, background: p.input, border: '1px solid ' + p.border, borderRadius: 10, padding: '11px 13px' } },
h('span', { style: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 } },
h('span', { style: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '0.06em', textTransform: 'uppercase', color: p.t4 } }, 'Logging for'),
h('span', { style: { fontSize: 15, fontWeight: 600, color: p.t1 } }, t.name)),
h('button', { onClick: () => this.setDraft({ targetId: null }), style: { flex: 'none', background: 'none', border: 'none', color: p.accentlight, fontSize: 13, cursor: 'pointer' } }, 'Change')),
label('Type'),
h('div', { style: { display: 'flex', gap: 8 } }, types.map(tp => {
const on = sh.type === tp; const tc = this.noteTag(tp, theme);
return h('button', { key: tp, onClick: () => this.setDraft({ type: tp }), style: { flex: 1, height: 40, borderRadius: 7, cursor: 'pointer', fontFamily: 'var(--mono)', fontSize: 12, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', border: '1px solid ' + (on ? p.bstrong : p.border), background: on ? tc.bg : p.input, color: on ? tc.text : p.t3 } }, tp);
})),
label('Summary'),
h('input', { value: sh.summary, onChange: e => this.setDraft({ summary: e.target.value }), style: inputStyle, placeholder: 'Short headline', autoFocus: true }),
label('Details'),
h('textarea', { value: sh.details, onChange: e => this.setDraft({ details: e.target.value }), style: areaStyle, placeholder: 'Full context kept in communications history' }),
help('Posts to ' + t.name + '\u2019s timeline via the one-row log path and bumps last contact to today.'),
primaryBtn('Log communication', () => {
const entry = [sh.type, sh.summary.trim(), '2026-06-19'];
if (window.T31Store) window.T31Store.logNote(t.id, entry);
this.setState({ sheet: null }); this.toast('Logged for ' + t.name);
}, !sh.summary.trim())
);
}
return null;
}
}
</script>
</body>
</html>