Mobile Phase 6: app-wide light theme + [data-theme] toggle

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.
This commit is contained in:
Keysat
2026-06-19 16:38:30 -05:00
parent 7f711d1fae
commit e6a89450da
21 changed files with 5521 additions and 374 deletions
@@ -0,0 +1,489 @@
<!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; } }
.cn-scroll::-webkit-scrollbar { width: 0; height: 0; }
.cn-root button, .cn-root input, .cn-root textarea { font-family: inherit; }
.cn-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;
}
.cn-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;
}
</style>
</helmet>
<div class="cn-root" data-theme="{{ themeAttr }}" 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>
<!-- title + search -->
<div style="flex:none; padding:14px 16px 12px;">
<div style="display:flex; align-items:baseline; justify-content:space-between;">
<span style="font-size:21px; font-weight:600; letter-spacing:-0.01em;">Contacts</span>
<span style="font-family:var(--mono); font-size:12px; color:var(--t4);">{{ countLabel }}</span>
</div>
<input value="{{ search }}" onInput="{{ onSearch }}" placeholder="Search name, email, or firm…" style="width:100%; height:44px; margin-top:13px; 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; box-sizing:border-box;" />
</div>
<!-- directory list -->
<div class="cn-scroll" style="flex:1; min-height:0; overflow-y:auto; padding:2px 16px 20px; display:flex; flex-direction:column;">
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="6">
<div style="position:sticky; top:0; z-index:2; background:var(--base); padding:10px 4px 6px; font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.1em; color:var(--t4);">{{ g.letter }}</div>
<div style="display:flex; flex-direction:column; gap:8px; padding-bottom:6px;">
<sc-for list="{{ g.people }}" as="c" hint-placeholder-count="2">
<button onClick="{{ c.open }}" style="text-align:left; cursor:pointer; width:100%; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:11px 13px; box-shadow:var(--shadow-card); display:flex; align-items:center; gap:12px; color:var(--t1);">
<span style="flex:none; width:40px; height:40px; border-radius:999px; background:var(--elev); border:{{ c.ring }}; display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-size:13px; font-weight:600; color:var(--accentlight);">{{ c.initials }}</span>
<span style="flex:1; min-width:0; display:flex; flex-direction:column; gap:3px;">
<span style="font-size:15px; font-weight:600; line-height:1.2; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ c.name }}</span>
<span style="display:flex; align-items:center; gap:8px; min-width:0;">
<span style="font-size:13px; color:var(--t3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ c.org }}</span>
<span style="flex:none; font-family:var(--mono); font-size:9px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; padding:2px 7px; border-radius:999px; background:{{ c.stageBg }}; color:{{ c.stageText }}; border:1px solid {{ c.stageBorder }};">{{ c.stage }}</span>
</span>
</span>
<span style="flex:none; font-family:var(--mono); font-size:11px; color:{{ c.lastColor }};">{{ c.last }}</span>
</button>
</sc-for>
</div>
</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 contacts match.</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>
<!-- contact detail sheet -->
<sc-if value="{{ detailOpen }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ closeDetail }}" 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:0 20px 26px; max-height:90%; 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 class="cn-scroll" style="overflow-y:auto;">
<!-- header -->
<div style="display:flex; align-items:center; gap:13px; padding:6px 0 4px;">
<span style="flex:none; width:52px; height:52px; border-radius:999px; background:var(--elev); border:{{ d.ring }}; display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-size:16px; font-weight:600; color:var(--accentlight);">{{ d.initials }}</span>
<div style="min-width:0;">
<div style="font-size:19px; font-weight:600; line-height:1.2;">{{ d.name }}</div>
<div style="font-size:13px; color:var(--t3); margin-top:2px;">{{ d.org }}</div>
</div>
</div>
<!-- email -->
<button onClick="{{ copyEmail }}" style="width:100%; text-align:left; cursor:pointer; margin-top:14px; background:var(--input); border:1px solid var(--border); border-radius:10px; padding:12px 14px; display:flex; align-items:center; justify-content:space-between; gap:10px; color:var(--t1);">
<span style="display:flex; flex-direction:column; gap:3px; min-width:0;">
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.06em; text-transform:uppercase; color:var(--t4);">Email</span>
<span style="font-family:var(--mono); font-size:14px; color:var(--accentlight); overflow:hidden; text-overflow:ellipsis;">{{ d.email }}</span>
</span>
<span style="flex:none; font-family:var(--mono); font-size:11px; color:var(--t4);">copy</span>
</button>
<!-- actions -->
<div style="display:flex; gap:10px; margin-top:12px;">
<button onClick="{{ logForContact }}" style="flex:2; height:46px; border-radius:8px; border:none; background:linear-gradient(#3b82c4,#2f6ea9); color:#fff; font-size:14px; font-weight:600; cursor:pointer; box-shadow:0 6px 14px rgba(12,40,68,0.35);">Log communication</button>
<button onClick="{{ draftEmail }}" style="flex:1; height:46px; border-radius:8px; border:1px solid var(--bstrong); background:var(--elev); color:var(--t2); font-size:14px; font-weight:500; cursor:pointer;">Email</button>
</div>
<!-- organization -->
<div style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3); margin:20px 0 9px;">Organization</div>
<div style="background:var(--input); border:1px solid var(--border); border-radius:10px; padding:13px 14px; display:flex; flex-direction:column; gap:11px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px;">
<span style="display:flex; align-items:center; gap:8px; min-width:0;">
<sc-if value="{{ d.existing }}" hint-placeholder-val="{{ false }}"><span style="flex:none; width:0; height:0; border-top:13px solid var(--accent); border-right:13px solid transparent;"></span></sc-if>
<span style="font-size:15px; font-weight:600; overflow:hidden; text-overflow:ellipsis;">{{ d.org }}</span>
</span>
<span style="flex:none; font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; padding:3px 9px; border-radius:999px; background:{{ d.stageBg }}; color:{{ d.stageText }}; border:1px solid {{ d.stageBorder }};">{{ d.stage }}</span>
</div>
<div style="display:flex; align-items:center; justify-content:space-between;">
<span style="display:flex; flex-direction:column; gap:3px;">
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.06em; text-transform:uppercase; color:var(--t4);">Committed</span>
<span style="font-family:var(--mono); font-size:15px; font-weight:600; color:{{ d.amtColor }};">{{ d.amount }}</span>
</span>
<span style="display:flex; flex-direction:column; gap:3px; text-align:right;">
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.06em; text-transform:uppercase; color:var(--t4);">Last contact</span>
<span style="font-family:var(--mono); font-size:14px; color:{{ d.lastColor }};">{{ d.last }}</span>
</span>
</div>
<sc-if value="{{ d.hasNote }}" hint-placeholder-val="{{ false }}">
<div style="border-top:1px solid var(--divider); padding-top:10px; 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:{{ d.noteBg }}; color:{{ d.noteText }};">{{ d.noteType }}</span>
<span style="font-size:13px; color:var(--t2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ d.noteSummary }}</span>
</div>
</sc-if>
<button onClick="{{ openInGrid }}" style="align-self:flex-start; background:none; border:none; padding:0; cursor:pointer; color:var(--accentlight); font-size:13px;">Open investor in Grid </button>
</div>
</div>
</div>
</div>
</sc-if>
<!-- log communication sheet (also pencil quick-log) -->
<sc-if value="{{ logOpen }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ closeLog }}" style="position:absolute; inset:0; z-index:65; background:rgba(4,9,16,0.6); 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:90%; 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 4px; flex:none;">
<span style="font-size:18px; font-weight:600; color:var(--t1);">Log communication</span>
<button onClick="{{ closeLog }}" style="background:none; border:none; color:var(--t3); font-size:22px; cursor:pointer; line-height:1; padding:0 4px;">×</button>
</div>
<div class="cn-scroll" style="overflow-y:auto; margin-top:10px;">
{{ logBody }}
</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;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;}}">
class Component extends DCLogic {
constructor(props) {
super(props);
this.state = {
theme: props.theme === 'light' ? 'light' : 'dark',
search: '',
accountMenu: false,
detailId: null, // contactId
log: null, // { contactId|null, type, summary, details, q }
toast: null,
};
}
componentDidMount() { if (window.T31Store) this._unsub = window.T31Store.subscribe(() => this.forceUpdate()); }
componentWillUnmount() { if (this._unsub) this._unsub(); }
seed() {
const C = (name, email) => ({ name, email });
return [
{ id: 1, name: 'Northwall Capital', stage: 'commitment', daysAgo: 2, amt: 2500000,
contacts: [C('Dana Reyes', 'dana@northwall.com'), C('Per Holt', 'per@northwall.com')],
notes: [['Email', 'Confirmed $2.5M allocation across funds', '2026-06-17']] },
{ id: 2, name: 'Brightseed Partners', stage: 'engaged', daysAgo: 5, amt: 0,
contacts: [C('Omar Said', 'omar@brightseed.vc')], notes: [['Note', 'Intro from Polaris — warm', '2026-06-14']] },
{ id: 3, name: 'Cedarline Family Office', stage: 'commitment', daysAgo: 7, amt: 1200000,
contacts: [C('Lena Cho', 'lena@cedarline.com')], notes: [['Call', 'Wire received, fully funded', '2026-06-12']] },
{ id: 4, name: 'Vance & Co', stage: 'engaged', daysAgo: 3, amt: 0,
contacts: [C('Marcus Vance', 'mv@vanceco.com')], notes: [] },
{ id: 5, name: 'Polaris Endowment', stage: 'diligence', daysAgo: 1, amt: 5000000,
contacts: [C('Ruth Almeida', 'ralmeida@polaris.org')], notes: [['Meeting', 'IC presentation went well', '2026-06-18']] },
{ id: 7, name: 'Meridian Trust', stage: 'commitment', daysAgo: 4, amt: 800000,
contacts: [C('Sofia Marin', 'sofia@meridiantrust.com')], notes: [['Note', 'Signed side letter', '2026-06-14']] },
{ id: 8, name: 'Atlas Ventures Fund', stage: 'engaged', daysAgo: 6, amt: 0,
contacts: [C('Will Tanaka', 'will@atlasvf.com')], notes: [] },
{ id: 9, name: 'K. Whitfield', stage: null, daysAgo: 21, amt: 0,
contacts: [C('Kira Whitfield', 'kira@whitfield.io')], notes: [['Note', 'No allocation — parked', '2026-05-28']] },
{ id: 10, name: 'Granite Bay LP', stage: 'commitment', daysAgo: 30, amt: 3300000,
contacts: [C('Tom Becker', 'tom@granitebay.com')], notes: [] },
];
}
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' });
}
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'];
}
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' };
}
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;
}
initials(name) {
const parts = name.replace(/[^A-Za-z .]/g, '').trim().split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
stageLabel(s) { return s || 'no stage'; }
// flatten investors → contacts (from the shared store)
allContacts() {
const out = [];
const S = window.T31Store;
const list = S ? S.investors : [];
list.forEach(inv => (inv.contacts || []).forEach((c, idx) => {
out.push({ cid: inv.id * 100 + idx, name: c.name, email: c.email, orgId: inv.id, org: inv.name, stage: inv.stage, amt: S ? S.committed(inv) : 0, daysAgo: inv.daysAgo, notes: inv.notes });
}));
return out;
}
contactById(cid) { return this.allContacts().find(c => c.cid === cid); }
toast(msg) { this.setState({ toast: msg }); clearTimeout(this._tt); this._tt = setTimeout(() => this.setState({ toast: null }), 2000); }
setLog(patch) { this.setState(s => ({ log: Object.assign({}, s.log, patch) })); }
saveLog() {
const lg = this.state.log; if (!lg || !lg.contactId || !lg.summary.trim()) return;
const c = this.contactById(lg.contactId);
const entry = [lg.type, lg.summary.trim(), '2026-06-19'];
if (window.T31Store) window.T31Store.logNote(c.orgId, entry);
this.setState({ log: null });
this.toast('Logged for ' + c.name);
}
renderVals() {
const s = this.state;
const theme = s.theme;
const q = s.search.trim().toLowerCase();
let people = this.allContacts();
if (q) people = people.filter(c => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q) || c.org.toLowerCase().includes(q));
people.sort((a, b) => a.name.localeCompare(b.name));
const mk = (c) => {
const sc = this.stageColors(c.stage, theme);
const rec = this.recency(c.daysAgo, theme);
return {
cid: c.cid, name: c.name, org: c.org, initials: this.initials(c.name),
ring: c.amt > 0 ? '1.5px solid var(--accent)' : '1px solid var(--border)',
stage: this.stageLabel(c.stage), stageBg: sc.bg, stageText: sc.text, stageBorder: sc.border,
last: rec.text, lastColor: rec.color,
open: () => this.setState({ detailId: c.cid }),
};
};
// group by first letter of name
const groups = [];
people.forEach(c => {
const letter = c.name[0].toUpperCase();
let g = groups[groups.length - 1];
if (!g || g.letter !== letter) { g = { letter, people: [] }; groups.push(g); }
g.people.push(mk(c));
});
// detail
const sel = s.detailId ? this.contactById(s.detailId) : null;
let d = null;
if (sel) {
const sc = this.stageColors(sel.stage, theme);
const rec = this.recency(sel.daysAgo, theme);
const note = sel.notes && sel.notes[0];
const nt = note ? this.noteTag(note[0], theme) : null;
d = {
name: sel.name, org: sel.org, email: sel.email, initials: this.initials(sel.name),
ring: sel.amt > 0 ? '1.5px solid var(--accent)' : '1px solid var(--border)',
existing: sel.amt > 0,
stage: this.stageLabel(sel.stage), stageBg: sc.bg, stageText: sc.text, stageBorder: sc.border,
amount: this.money(sel.amt), amtColor: sel.amt > 0 ? (theme === 'light' ? '#057a55' : '#6ee7b7') : 'var(--t4)',
last: rec.text, lastColor: rec.color,
hasNote: !!note, noteType: note ? note[0].toUpperCase() : '', noteSummary: note ? note[1] : '',
noteBg: nt ? nt.bg : '', noteText: nt ? nt.text : '',
};
}
// tabs
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 === 'contacts' ? 'var(--accent)' : 'var(--t4)',
icon: this.tabIcon(t.key, t.key === 'contacts'),
go: () => { if (window.T31Store) window.T31Store.setTab(t.key); },
}));
const logBody = s.log ? this.buildLog(s.log) : null;
return {
themeAttr: theme, themeIcon: theme === 'light' ? '☾' : '☀',
toggleTheme: () => { const t = theme === 'light' ? 'dark' : 'light'; if (window.T31Store) window.T31Store.setTheme(t); this.setState({ theme: t }); },
toggleAccount: () => this.setState(st => ({ accountMenu: !st.accountMenu })),
closeAccount: () => this.setState({ accountMenu: false }),
accountMenu: s.accountMenu,
countLabel: people.length + (people.length === 1 ? ' person' : ' people'),
search: s.search, onSearch: e => this.setState({ search: e.target.value }),
groups, listEmpty: people.length === 0, tabs,
// detail
detailOpen: !!sel, d,
closeDetail: () => this.setState({ detailId: null }),
stop: e => e.stopPropagation(),
copyEmail: () => this.toast('Email copied'),
draftEmail: () => this.toast('Drafting email to ' + (sel ? sel.email : '')),
openInGrid: () => { if (window.T31Store && sel) window.T31Store.openInvestor(sel.orgId); },
logForContact: () => this.setState({ log: { contactId: sel.cid, type: 'Note', summary: '', details: '', q: '' } }),
openInGridLabel: 'Open investor in Grid',
// log
openQuickLog: () => this.setState({ log: { contactId: null, type: 'Note', summary: '', details: '', q: '' } }),
closeLog: () => this.setState({ log: null }),
logOpen: !!s.log, logBody,
toast: s.toast,
};
}
buildLog(lg) {
const h = React.createElement;
const theme = this.state.theme;
const dark = theme !== 'light';
const T = { input: dark ? '#0d1622' : '#eef2f7', border: dark ? '#263548' : '#d6dde7', bstrong: dark ? '#35506a' : '#b6c3d4',
t1: dark ? '#e5edf5' : '#16202c', t2: dark ? '#c7d3e0' : '#33414f', t3: dark ? '#8ea2b7' : '#5a6b7d', t4: dark ? '#70859b' : '#84909e',
elev: dark ? '#152233' : '#f4f7fb', accentlight: dark ? '#93c5fd' : '#1f6fb8', panel: dark ? '#111a27' : '#fff' };
const inputStyle = { width: '100%', height: 46, background: T.input, border: '1px solid ' + T.border, borderRadius: 8, color: T.t1, fontFamily: 'var(--sans)', fontSize: 15, padding: '0 14px', outline: 'none', boxSizing: 'border-box' };
const label = (t) => h('div', { style: { fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '0.08em', textTransform: 'uppercase', color: T.t3, margin: '16px 0 8px' } }, t);
// phase 1: pick contact
if (!lg.contactId) {
const qn = (lg.q || '').trim().toLowerCase();
let pool = this.allContacts();
if (qn) pool = pool.filter(c => c.name.toLowerCase().includes(qn) || c.email.toLowerCase().includes(qn) || c.org.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: T.t3, lineHeight: 1.5, marginBottom: 12 } }, 'Pick a contact, then log the communication.'),
h('input', { value: lg.q, onChange: e => this.setLog({ q: e.target.value }), style: inputStyle, placeholder: 'Search contact or firm…', autoFocus: true }),
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 } }, pool.length ? pool.map(c => {
const sc = this.stageColors(c.stage, theme);
return h('button', { key: c.cid, onClick: () => this.setLog({ contactId: c.cid }), style: { width: '100%', textAlign: 'left', cursor: 'pointer', background: T.input, border: '1px solid ' + T.border, borderRadius: 10, padding: '11px 13px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, color: T.t1 } },
h('span', { style: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 } },
h('span', { style: { fontSize: 15, fontWeight: 500 } }, c.name),
h('span', { style: { fontSize: 12, color: T.t3 } }, c.org)),
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(c.stage)));
}) : h('div', { style: { fontSize: 13, color: T.t4, padding: '16px 4px' } }, 'No matches.'))
);
}
// phase 2: form
const c = this.contactById(lg.contactId);
const types = ['Note', 'Email', 'Call', 'Meeting'];
const disabled = !lg.summary.trim();
return h('div', null,
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, background: T.input, border: '1px solid ' + T.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: T.t4 } }, 'Logging for'),
h('span', { style: { fontSize: 15, fontWeight: 600, color: T.t1 } }, c.name + ' · ' + c.org)),
h('button', { onClick: () => this.setLog({ contactId: null }), style: { flex: 'none', background: 'none', border: 'none', color: T.accentlight, fontSize: 13, cursor: 'pointer' } }, 'Change')),
label('Type'),
h('div', { style: { display: 'flex', gap: 8 } }, types.map(tp => {
const on = lg.type === tp; const tc = this.noteTag(tp, theme);
return h('button', { key: tp, onClick: () => this.setLog({ 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 ? T.bstrong : T.border), background: on ? tc.bg : T.input, color: on ? tc.text : T.t3 } }, tp);
})),
label('Summary'),
h('input', { value: lg.summary, onChange: e => this.setLog({ summary: e.target.value }), style: inputStyle, placeholder: 'Short headline', autoFocus: true }),
label('Details'),
h('textarea', { value: lg.details, onChange: e => this.setLog({ details: e.target.value }), style: Object.assign({}, inputStyle, { height: 92, padding: '12px 14px', resize: 'none', lineHeight: 1.45 }), placeholder: 'Full context kept in communications history' }),
h('div', { style: { fontSize: 12, color: T.t4, marginTop: 8, lineHeight: 1.45 } }, 'Posts to ' + c.org + '\u2019s timeline via the one-row log path and bumps last contact to today.'),
h('button', { onClick: () => this.saveLog(), disabled: disabled, style: { width: '100%', height: 48, marginTop: 18, borderRadius: 8, border: 'none', background: disabled ? T.elev : 'linear-gradient(#3b82c4,#2f6ea9)', color: disabled ? T.t4 : '#fff', fontSize: 15, fontWeight: 600, cursor: disabled ? 'default' : 'pointer' } }, 'Log communication')
);
}
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 = (pp) => React.createElement('rect', pp);
const ln = (pp) => React.createElement('line', Object.assign({}, pp, { 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' }),
]);
}
}
</script>
</body>
</html>