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.
This commit is contained in:
+47
@@ -269,6 +269,53 @@ Items 3–6 are cheap (derived/read-time/frontend, reuse `last_activity_at`, no
|
||||
- **Last-contact recency** carries the staleness color (grey→amber→red, "Nd stale").
|
||||
- This **replaces the design-mockup's INVESTOR/PROSPECT category chip** — we have no prospect/investor *type*; that two-value badge was the tool deriving committed-$>0, which is exactly our Existing-Investor flag. Feeds `design/BRIEF.md` §3a.
|
||||
|
||||
### Mobile-first implementation — backlog (design landed 2026-06-19)
|
||||
*The `/design` round-trip is complete: the contract now describes the mobile-first system
|
||||
(`design/DESIGN.md` §8 + the `mobile` token group), provenance + per-surface interaction model
|
||||
are in `design/_imports/2026-06-19/`, and the input brief is `design/BRIEF.md`. This is the gap
|
||||
between that contract and the current desktop-only `frontend/index.html` — the implementation
|
||||
backlog. **Not yet started; scope/plan to be developed next (the user's stated next step).**
|
||||
The comps are signed-off prototypes, **not drop-in** (Claude Design runtime, seed data) — each
|
||||
surface is re-authored in the app's React idiom and wired to the **real API**.*
|
||||
|
||||
**Hard prerequisite — inline-style→CSS migration.** Responsive layout cannot live in the
|
||||
~1,300 inline `style={{}}` objects (they can't carry media queries). Mobile-first means
|
||||
authoring a 375px base + `min-width` enhancements in the CSS `<style>` block / utility classes.
|
||||
This migration is **large and not yet scoped** — it gates everything below and is the first
|
||||
thing the implementation plan must size. (Precedent for a mechanical sweep: the design guide's
|
||||
inline-hex→`var()` field notes; this is bigger — structural layout, not just values.)
|
||||
|
||||
**Data-layer dependency — the locked pipeline-stages/flags spec** (see the section above) lands
|
||||
**first or together**: the mobile cards render the 4-stage chip, the auto-derived
|
||||
Existing-Investor star, and the staleness overlay, all of which need the stage enum + migration +
|
||||
`total_invested>0` derivation + the `last_activity_at` ramp. Building the cards before the data
|
||||
layer means hardcoding against a model that's about to change.
|
||||
|
||||
**UI workstreams (rough order; real sequencing comes with the plan):**
|
||||
1. **Responsive shell + nav:** viewport-gated mobile shell, the safe-area-aware **4-tab bottom
|
||||
bar** (Grid·Pipeline·Reminders·Contacts), the top-bar account/logout control, the bottom-sheet
|
||||
primitive, and the type/touch bump — the chrome every surface shares.
|
||||
2. **Grid (do first — canonical + the crux):** card list + bottom-sheet **view picker** + search;
|
||||
full-screen detail with per-field bottom-sheet edits (name, contact pills, stage, reminder, log
|
||||
note) + the `+`-create flow with client-side dedup typeahead. **Writes go through the targeted
|
||||
one-row `POST /api/fundraising/log-communication` path + the pipeline link→`PATCH stage` flow —
|
||||
never whole-grid `PUT /state`** (the `BRIEF.md` §3a "Backend reality"). Commitments read-only.
|
||||
3. **Contacts (lowest-risk validator):** read-only A–Z list + full-screen detail; proves the
|
||||
list+detail+sheet pattern before the heavier surfaces.
|
||||
4. **Pipeline:** swipe-between-stages (snap-scroll + segmented control + dots), per-card stage move
|
||||
sharing the same opportunities endpoints as the Grid detail.
|
||||
5. **Reminders:** urgency-grouped list, swipe complete/snooze, add/edit sheets on `/api/reminders`.
|
||||
6. **Light theme + toggle (adopted as a planned feature, 2026-06-19).** Ship the light palette
|
||||
(`tokens.tokens.json` `color.light`) behind a `[data-theme]` switch + a top-bar toggle; dark
|
||||
stays the default. Naturally co-lands with the inline-style→CSS migration (theming wants CSS
|
||||
custom properties, not per-element inline values). Per-component light tints (stage/staleness/
|
||||
note badges) are in `_imports/2026-06-19/GridApp.dc.html`.
|
||||
|
||||
**Note on `design-checker`:** not run for this round-trip — it audits *existing* UI conformance,
|
||||
and the desktop UI still conforms to §1–7 (unchanged). The mobile gap is greenfield
|
||||
implementation (captured here), not conformance drift, so there's nothing for it to flag yet; run
|
||||
it after the mobile surfaces exist.
|
||||
|
||||
## Definition of done for "Airtable substitute" v1
|
||||
- Team can manage all investors in one master table
|
||||
- Saved views replicate current Airtable workflows
|
||||
|
||||
+75
-8
@@ -9,7 +9,9 @@ not the visual language below.*
|
||||
|
||||
## 1. Visual theme
|
||||
|
||||
A dense, professional, **dark** venture-CRM workspace — calm, data-forward, slightly
|
||||
A dense, professional, **dark** venture-CRM workspace (a **light** theme is planned as an
|
||||
optional toggle — see §8 — but dark is the default and the brand's primary identity) — calm,
|
||||
data-forward, slightly
|
||||
"terminal/financial." Cool blue-greys throughout, a single saturated blue as the only vivid
|
||||
accent, and IBM Plex's engineered-but-humane character. Monospace (IBM Plex Mono) carries
|
||||
all numbers, dates, and codes, reinforcing the data-tool feel. The mood is *trustworthy
|
||||
@@ -35,6 +37,15 @@ Canonical values live in `design/tokens.tokens.json`. Summary:
|
||||
|
||||
White (`#ffffff`) appears only as text on accent fills and in the brand mark.
|
||||
|
||||
- **Pipeline-stage chips (redesign)** reuse existing semantic tints — no new hues: lead =
|
||||
subtle grey, engaged = accent blue, diligence = due-soon `#e0b341`, commitment = success
|
||||
`#10b981`/`#6ee7b7`. **Staleness** (last-contact age) overlays the same ramp: fresh grey →
|
||||
due-soon amber `#e0b341` → danger-soft red `#e06c6c`/`#f87171`. Both are tinted-fill + tinted-
|
||||
text badges, consistent with the badge rule above.
|
||||
- **Light theme (planned)** mirrors every slot above in `tokens.tokens.json` `color.light` (e.g.
|
||||
base `#eaeef3`, panel `#ffffff`, text-primary `#16202c`); accent `#3b82c4` is unchanged. Dark is
|
||||
the default — see §8.
|
||||
|
||||
## 3. Typography
|
||||
|
||||
- **Families:** `IBM Plex Sans` (UI/body), `IBM Plex Mono` (numbers, dates, badges, logs,
|
||||
@@ -44,8 +55,12 @@ White (`#ffffff`) appears only as text on accent fills and in the brand mark.
|
||||
title · 24px login title & KPI value.
|
||||
- **Treatments:** global letter-spacing `0.01em`; table headers uppercase with `0.08em`
|
||||
tracking; badges uppercase `0.5px`; numbers use `font-variant-numeric: tabular-nums`.
|
||||
- **Mobile note:** 13px body is comfortable for desktop and tight on a phone — the redesign
|
||||
bumps the mobile base toward 15–16px. See `BRIEF.md`.
|
||||
- **Mobile scale (redesign, sourced from the 2026-06-19 comps):** body/inputs/list rows
|
||||
**15px** (up from desktop's 13px); card investor name 16/600; mono amounts 15/600; screen
|
||||
title 21/600 (vs desktop 20); full-screen detail title 22/600; sheet title 18/600; section
|
||||
labels mono-uppercase 11/600 `0.08em`; chips mono-uppercase 10–11/600; meta/last-contact
|
||||
mono 12; bottom-tab label mono 10. Inputs are 44–46px tall for touch. The mono-for-numbers
|
||||
and uppercase-tracked-badge rules are unchanged — only the base size grows.
|
||||
|
||||
## 4. Component styling
|
||||
|
||||
@@ -64,6 +79,29 @@ White (`#ffffff`) appears only as text on accent fills and in the brand mark.
|
||||
- **Other:** kanban cards (radius 6), toasts (bottom-right), accent spinner, shimmer
|
||||
skeletons, left-marker timeline for activity feeds.
|
||||
|
||||
**Mobile component states (redesign, from the 2026-06-19 comps — see §8 and `_imports/`):**
|
||||
- **Bottom tab bar:** exactly four tabs (Grid · Pipeline · Reminders · Contacts), 56px tall,
|
||||
border-top, translucent panel bg + `backdrop-filter: blur(8px)`, `padding-bottom` for
|
||||
`env(safe-area-inset-bottom)`. Active tab = accent (icon + 10px mono label); inactive =
|
||||
subtle. 20px line-icons.
|
||||
- **Bottom sheet** (replaces the centered modal + right slide-over on mobile): panel bg,
|
||||
strong top border, **radius-20 top corners**, 38×4 grey drag handle, slide-up
|
||||
(`translateY(100%)→0`, 280ms `cubic-bezier(.2,.8,.2,1)`), scrim `rgba(4,9,16,0.55)` fade-in,
|
||||
max-height ~88–90%, scroll-body; tap-scrim or × to dismiss. One field/action per sheet.
|
||||
- **Mobile card** (the table→card transform): panel, 1px border, **radius-10**, card shadow,
|
||||
12–14px padding; name 16/600 + Priority corner badge (top-right); mono amount + stage chip +
|
||||
staleness last-contact. Existing-LP = quiet accent **corner earmark** (star is the lighter
|
||||
alternative; not a per-card banner).
|
||||
- **Full-screen detail** (promotes the desktop slide-over): `screenIn` slide (translateX 14px,
|
||||
200ms), "‹ Grid" back affordance, grouped read-only sections with per-field edit entry points.
|
||||
- **Swipe actions** (Reminders): pointer-drag a card to reveal complete (swipe-left) / snooze
|
||||
(swipe-right) at a 70px threshold; the action color tints in under the card.
|
||||
- **Snap-scroll stages** (Pipeline): full-width stage columns, `scroll-snap-type: x mandatory`,
|
||||
a segmented stage control + page dots; per-card `‹ / ›` stage move.
|
||||
- **Account control:** a top-bar avatar opening a small popover (profile + logout) — the only
|
||||
non-tab navigation on mobile.
|
||||
- **Toast:** mobile position is bottom-center **above the tab bar** (not bottom-right).
|
||||
|
||||
## 5. Layout
|
||||
|
||||
Desktop shell = **fixed 250px left sidebar + flexible main content** with a top header bar
|
||||
@@ -102,11 +140,40 @@ sidebar simply `display:none`s below 768px with no real mobile navigation; wide
|
||||
overflow horizontally; the 400px slide-over overflows a 375px screen. A correct viewport
|
||||
meta tag is present.
|
||||
|
||||
**Target: mobile-first, mobile-preferred — active redesign.** The team increasingly works
|
||||
from phones, so mobile is becoming the primary surface. The full plan (navigation
|
||||
re-architecture to a bottom tab bar, table→card transforms, bottom sheets, touch sizing,
|
||||
type bump) lives in **`design/BRIEF.md`**. Update this section to describe the new
|
||||
mobile-first system once that redesign lands.
|
||||
**Target: mobile-first, mobile-preferred.** The design landed via a Claude Design round-trip
|
||||
(2026-06-19; source + per-surface interaction model in `design/_imports/2026-06-19/`, input
|
||||
brief in `design/BRIEF.md`). The system:
|
||||
|
||||
- **Author base = a 375px column, enhance up** with `min-width` breakpoints for tablet/desktop
|
||||
(inverting today's `max-width` rules). **Prerequisite:** responsive layout must live in the
|
||||
CSS `<style>` block / utility classes — the ~1,300 inline `style={{}}` objects can't respond
|
||||
to media queries, so an **inline-style→CSS migration** gates this work (tracked in `ROADMAP.md`).
|
||||
- **Mobile is a focused subset, not the whole app:** only **four surfaces** ship on mobile —
|
||||
**Fundraising Grid · Pipeline · Reminders · Contacts** — in a **bottom tab bar**; every other
|
||||
screen (Dashboard, Thesis, Outreach, Settings, …) is desktop-only and absent, with just a
|
||||
minimal top-bar account/logout control.
|
||||
- **Navigation:** the 250px sidebar → a safe-area-aware **bottom tab bar of four**. No "More"
|
||||
menu.
|
||||
- **Transforms:** wide data table → **investor card list + full-screen detail**; Pipeline kanban
|
||||
→ **swipe-between-stages** (snap-scroll, segmented control, per-card stage move); centered
|
||||
modal + 400px slide-over → **drag-to-dismiss bottom sheets** / full-screen detail; Grid saved
|
||||
*views* → a tappable view-name opening a **bottom-sheet view picker**.
|
||||
- **Editing on mobile** is the on-the-go core only — investor name, contact pills, notes/comm
|
||||
log, pipeline stage, reminders, and new-investor create (via the Grid, the canonical write
|
||||
path). Commitments/amounts and the full column set stay **read-only / desktop-only**. (Write
|
||||
paths use the targeted one-row `log-communication` endpoint + the pipeline link→stage flow,
|
||||
**not** whole-grid saves — see `BRIEF.md` §3a "Backend reality.")
|
||||
- **Touch + safe areas:** 44px minimum primary touch targets; body type bumped 13→15px (§3);
|
||||
sticky bottom nav respects `env(safe-area-inset-bottom)` and content gets bottom padding so
|
||||
the nav never overlaps it.
|
||||
- **Light theme — planned (adopted 2026-06-19).** Ship the light palette (`tokens.tokens.json`
|
||||
`color.light`) behind a `[data-theme]` switch + a top-bar toggle. **Dark stays the default and
|
||||
the brand's primary identity;** light is the optional alternative. It co-lands with the
|
||||
inline-style→CSS migration (theming needs CSS custom properties, not per-element inline values).
|
||||
Full per-component light tints (stage/staleness/note badges) are in `_imports/2026-06-19/`.
|
||||
|
||||
The gap between this section and the current `index.html` is the implementation backlog in
|
||||
`ROADMAP.md` (incl. the inline-style→CSS migration and the locked pipeline-stages/flags spec).
|
||||
|
||||
## 9. Agent prompt guide
|
||||
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
<!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="{"$preview":{"width":393,"height":812},"variant":{"editor":"enum","options":["compact","roomy"],"default":"compact","tsType":"'compact'|'roomy'"},"theme":{"editor":"enum","options":["dark","light"],"default":"dark","tsType":"'dark'|'light'"},"font":{"editor":"enum","options":["plex","manrope","hanken"],"default":"plex","tsType":"'plex'|'manrope'|'hanken'"},"lpFlag":{"editor":"enum","options":["star","earmark","banner"],"default":"earmark","tsType":"'star'|'earmark'|'banner'"}}">
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Import bundle — mobile-first redesign round-trip (2026-06-19)
|
||||
|
||||
Provenance for the `/design` round-trip that produced the mobile-first design. This folder is
|
||||
**raw input / provenance** (per `~/Projects/standards/guides/design.md`); the durable contract
|
||||
is `design/DESIGN.md` + `design/tokens.tokens.json`, which were distilled **from** this bundle.
|
||||
|
||||
## Source
|
||||
|
||||
- **Tool:** Claude Design (cloud, `claude.ai/design`), driven by Grant.
|
||||
- **Project:** "Venture-CRM mobile redesign" — `91e62d47-4c4d-43fb-9135-edb05bc59970`
|
||||
- **URL:** https://claude.ai/design/p/91e62d47-4c4d-43fb-9135-edb05bc59970
|
||||
- **Input packet:** `design/BRIEF.md` (the mobile-first brief) + uploaded `DESIGN.md`,
|
||||
`tokens.tokens.json`, brand SVGs, and desktop screenshots.
|
||||
|
||||
## What's in this folder vs. still in the cloud project
|
||||
|
||||
The Claude Design MCP (`DesignSync`) streams file **content into context**; it has no
|
||||
bulk-download / binary-export path. So:
|
||||
|
||||
- **Byte-captured here:** `GridApp.dc.html` — the canonical surface, and the richest single
|
||||
artifact: it embodies the shared **data model**, the **derived-field formulas**, the stage/
|
||||
staleness/note **color logic**, and the locked **card model**. The other surfaces reuse the
|
||||
same patterns (verified by reading them).
|
||||
- **Recoverable from the cloud project (not byte-copied):** the shell + the three other app
|
||||
files, `store.js`, `support.js` (the DC runtime), the option-exploration files, and **all
|
||||
screenshots**. The full inventory + the distilled logic below preserve the design intent in
|
||||
text; re-pull any specific file from the project URL on request.
|
||||
|
||||
> These are **Claude Design runtime prototypes** (`<x-dc>` / `<sc-if>` / `<sc-for>` + a
|
||||
> `DCLogic` class + `support.js`, fed by a client-only seed `store.js`). They are **not
|
||||
> drop-in** code for `frontend/index.html` (inline-Babel React, real API). They are the
|
||||
> visual + interaction spec; implementation is a separate scoped build (see `ROADMAP.md`).
|
||||
|
||||
## File inventory (cloud project)
|
||||
|
||||
**The signed-off mobile set (what "Implement" refers to):**
|
||||
- `Ten31 Mobile.dc.html` — shell: phone frame + 4-tab bottom bar over a shared
|
||||
`window.T31Store` singleton, switching between the four surfaces (dark + light theme).
|
||||
- `GridApp.dc.html` — Fundraising Grid (canonical). *[captured here]*
|
||||
- `PipelineApp.dc.html` — Pipeline (swipe-between-stages + accordion variant).
|
||||
- `RemindersApp.dc.html` — Reminders (urgency groups + swipe complete/snooze).
|
||||
- `ContactsApp.dc.html` — Contacts (read-only A–Z directory + detail).
|
||||
- `store.js` — shared client store (data model + derived helpers + mutations).
|
||||
- `support.js` — the Claude Design runtime (generic; not design content).
|
||||
|
||||
**Earlier explorations / option sheets (superseded by the App set):**
|
||||
- `Fundraising Grid Mobile.dc.html`, `Contacts Mobile.dc.html`, `Pipeline Mobile.dc.html`,
|
||||
`Reminders Mobile.dc.html` — static single-screen studies.
|
||||
- `Existing-LP Flag Options.dc.html` — star vs. corner-earmark vs. top-banner trial (the App
|
||||
set defaults to **earmark**; star is the lighter alternative).
|
||||
- `Font Options.dc.html` — IBM Plex vs. Manrope vs. Hanken Grotesk trial (kept **Plex**).
|
||||
|
||||
**`screenshots/`** (~25 PNGs, in-cloud) — per-state renders: grid cards/detail, sort, contact
|
||||
detail, pipeline dots/log, swipe-reveal, sheets (name/note/view), reminders, LP-flag, fonts.
|
||||
|
||||
**`uploads/`** (in-cloud) — the inputs we fed in (`BRIEF.md`, `DESIGN.md`, `tokens`, desktop
|
||||
screenshots); originals already live in `design/`.
|
||||
|
||||
## Data model (from `store.js`)
|
||||
|
||||
Investor = `{ id, name, priority:bool, stage:'lead'|'engaged'|'diligence'|'commitment'|null,
|
||||
daysAgo, contacts:[{name,email}], funds:[[fundName, amount], …], views:[…], notes:[[type,
|
||||
summary, isoDate], …] }`. Reminder = `{ id, note, orgId, due:iso, done:bool }`. One canonical
|
||||
copy in a `window` singleton so a stage move / logged comm / reminder edit on any tab reflects
|
||||
on the others. Mirrors the server model (grid is system of record; `daysAgo` ← server
|
||||
`last_activity_at`; commitments read-only on mobile).
|
||||
|
||||
## Derived-field formulas (sourced — reuse verbatim in implementation)
|
||||
|
||||
- **Committed $:** `sum(funds[].amount)`. **Existing-Investor** flag = `committed > 0`
|
||||
(auto-derived; not a stored field).
|
||||
- **Money format:** `≥1e6 → $N[.N]M` (drop `.0`); `≥1e3 → $NK`; `0 → $0`.
|
||||
- **Staleness** (last-contact overlay, one global threshold — values **TBD with team**):
|
||||
`AMBER=10`, `STALE=30` days. `<10` grey → `≥10` amber (`#e0b341` dark / `#a76a07` light) →
|
||||
`≥30` red + "`Nd stale`" (`#f87171` / `#c0322f`).
|
||||
- **Stage order:** `lead → engaged → diligence → commitment`. Stage chip shows **only when in
|
||||
pipeline** (`stage != null`).
|
||||
- **Reminder urgency buckets:** overdue (`<0d`) → today (`0`) → this-week (`1–7`) → later
|
||||
(`>7`), colors red / due-soon / accent / subtle.
|
||||
|
||||
## Color logic introduced by the comps (reconcile in the contract)
|
||||
|
||||
- **Stage chips** use semantic tints (within the existing tinted-badge idiom, not new hues):
|
||||
lead = subtle grey, engaged = accent blue, diligence = due-soon `#e0b341`, commitment =
|
||||
success `#10b981`/`#6ee7b7`.
|
||||
- **Light theme** — the comps add a full light palette + a theme toggle. **Adopted as a planned
|
||||
feature** (owner decision 2026-06-19): dark stays the default, light ships behind a toggle. Core
|
||||
palette is in `tokens.tokens.json` `color.light`; full per-component light tints live in
|
||||
`GridApp.dc.html` here. See `DESIGN.md` §8 + the mobile backlog in `ROADMAP.md`.
|
||||
|
||||
## Per-surface interaction model
|
||||
|
||||
- **Grid:** card list (name · committed · stage chip · staleness last-contact) + Existing-LP
|
||||
earmark + Priority corner badge; tappable view-name → bottom-sheet **view picker**; search +
|
||||
`+` create (name typeahead → existing-match guard). Tap card → **full-screen detail** with
|
||||
per-field **bottom-sheet** edits (name, contact pills, stage, reminder, log note); commitments
|
||||
read-only. Graveyard view renders muted (opacity 0.55).
|
||||
- **Pipeline:** segmented stage control + **snap-scroll** between full-width stage columns
|
||||
(page dots), per-card `‹ back / fwd ›` stage move, tap → detail/log sheet. Accordion variant
|
||||
included as the alternative.
|
||||
- **Reminders:** urgency-grouped list; **pointer-drag** card to reveal complete (swipe-left,
|
||||
threshold 70px) / snooze (swipe-right); `+` add; tap → edit sheet (note, investor, due chips).
|
||||
- **Contacts:** read-only A–Z grouped directory (sticky letter headers) + search; tap →
|
||||
full-screen read-only detail (email copy, linked investor, last note); pencil = quick-log.
|
||||
|
||||
**Shared chrome:** 46px status bar (cosmetic), top bar (·Ten31· + theme toggle + account
|
||||
avatar), 4-tab bottom bar (Grid·Pipeline·Reminders·Contacts; 56px tall; translucent +
|
||||
backdrop-blur; `padding-bottom:18px` for safe-area), bottom sheets (radius-20 top, 38×4 drag
|
||||
handle, `sheetUp` 280ms), toasts (above nav), account popover (profile + logout).
|
||||
@@ -36,6 +36,36 @@
|
||||
},
|
||||
"constant": {
|
||||
"white": { "$type": "color", "$value": "#ffffff", "$description": "Text on accent fills; brand mark." }
|
||||
},
|
||||
"light": {
|
||||
"$description": "Light-theme palette (planned; adopted 2026-06-19). The color.* values above are the DARK/default theme; these are the light-mode overrides for the same semantic slots, sourced from the comps' [data-theme=\"light\"] blocks. accent.default stays #3b82c4 in both themes. Full per-component light tints (stage/staleness/note badges) live in design/_imports/2026-06-19/GridApp.dc.html. Dark remains the default; light is a user toggle.",
|
||||
"bg": {
|
||||
"base": { "$type": "color", "$value": "#eaeef3" },
|
||||
"panel": { "$type": "color", "$value": "#ffffff" },
|
||||
"elevated": { "$type": "color", "$value": "#f4f7fb" },
|
||||
"input": { "$type": "color", "$value": "#eef2f7" },
|
||||
"hover": { "$type": "color", "$value": "#e6ecf4" }
|
||||
},
|
||||
"border": {
|
||||
"default": { "$type": "color", "$value": "#d6dde7" },
|
||||
"strong": { "$type": "color", "$value": "#b6c3d4" },
|
||||
"divider": { "$type": "color", "$value": "#e8edf3", "$description": "Dark-theme equivalent is #1c2735 (a distinct lighter-than-border divider the comps introduced)." }
|
||||
},
|
||||
"text": {
|
||||
"primary": { "$type": "color", "$value": "#16202c" },
|
||||
"secondary": { "$type": "color", "$value": "#33414f" },
|
||||
"muted": { "$type": "color", "$value": "#5a6b7d" },
|
||||
"subtle": { "$type": "color", "$value": "#84909e" }
|
||||
},
|
||||
"accent": {
|
||||
"light": { "$type": "color", "$value": "#1f6fb8", "$description": "On-light accent text (darker than the dark-theme #93c5fd)." }
|
||||
},
|
||||
"semantic": {
|
||||
"danger-soft": { "$type": "color", "$value": "#c0322f" },
|
||||
"success-text": { "$type": "color", "$value": "#057a55" }
|
||||
},
|
||||
"nav-bg": { "$type": "color", "$value": "#ffffffd9", "$description": "Translucent bottom-nav background (light)." },
|
||||
"shadow-card": { "$type": "shadow", "$value": "0 8px 20px rgba(40,70,110,0.10), inset 0 1px 0 #ffffff" }
|
||||
}
|
||||
},
|
||||
"font": {
|
||||
@@ -46,7 +76,7 @@
|
||||
"size": {
|
||||
"xs": { "$type": "dimension", "$value": "11px", "$description": "Table headers, badges, micro-labels." },
|
||||
"sm": { "$type": "dimension", "$value": "12px", "$description": "Help text, metadata." },
|
||||
"md": { "$type": "dimension", "$value": "13px", "$description": "Body / table cells / inputs (current desktop base). NOTE: bump toward 15–16px for mobile body — see BRIEF.md." },
|
||||
"md": { "$type": "dimension", "$value": "13px", "$description": "Body / table cells / inputs (desktop base). Mobile body base is 15px — see the `mobile` group + DESIGN.md §3/§8." },
|
||||
"lg": { "$type": "dimension", "$value": "14px", "$description": "Nav items." },
|
||||
"xl": { "$type": "dimension", "$value": "16px", "$description": "Section titles." },
|
||||
"2xl": { "$type": "dimension", "$value": "18px", "$description": "Modal titles." },
|
||||
@@ -88,7 +118,35 @@
|
||||
"motion": {
|
||||
"fast": { "$type": "duration", "$value": "120ms", "$description": "Press/transform feedback." },
|
||||
"base": { "$type": "duration", "$value": "150ms", "$description": "Hover color/shadow." },
|
||||
"panel": { "$type": "duration", "$value": "300ms", "$description": "Slide-over / toast entry." }
|
||||
"panel": { "$type": "duration", "$value": "300ms", "$description": "Slide-over / toast entry." },
|
||||
"sheet": { "$type": "duration", "$value": "280ms", "$description": "Mobile bottom-sheet slide-up (cubic-bezier(.2,.8,.2,1))." }
|
||||
},
|
||||
"mobile": {
|
||||
"$description": "Mobile-first redesign deltas (2026-06-19 Claude Design round-trip; provenance in design/_imports/2026-06-19/, behavior in DESIGN.md §8). The values above are the desktop base; these override/extend for the <768px surface. Colors are unchanged — stage chips and staleness reuse the semantic tokens above (engaged→accent, diligence→due-soon, commitment→success, stale→danger-soft).",
|
||||
"font-size": {
|
||||
"body": { "$type": "dimension", "$value": "15px", "$description": "Body / list rows / inputs (up from desktop md 13px)." },
|
||||
"card-title": { "$type": "dimension", "$value": "16px", "$description": "Investor/card name." },
|
||||
"amount": { "$type": "dimension", "$value": "15px", "$description": "Mono money values." },
|
||||
"screen-title": { "$type": "dimension", "$value": "21px", "$description": "Tab screen title." },
|
||||
"detail-title": { "$type": "dimension", "$value": "22px", "$description": "Full-screen detail header." },
|
||||
"sheet-title": { "$type": "dimension", "$value": "18px", "$description": "Bottom-sheet title." },
|
||||
"tab-label": { "$type": "dimension", "$value": "10px", "$description": "Mono bottom-tab label." }
|
||||
},
|
||||
"radius": {
|
||||
"card": { "$type": "dimension", "$value": "10px", "$description": "Mobile cards (vs desktop lg 8px)." },
|
||||
"control": { "$type": "dimension", "$value": "8px", "$description": "Mobile inputs/buttons (vs desktop md 6px)." },
|
||||
"sheet": { "$type": "dimension", "$value": "20px", "$description": "Bottom-sheet top corners." }
|
||||
},
|
||||
"size": {
|
||||
"touch-target": { "$type": "dimension", "$value": "44px", "$description": "Minimum primary touch target." },
|
||||
"input": { "$type": "dimension", "$value": "46px", "$description": "Form input / sheet field height." },
|
||||
"tab-bar": { "$type": "dimension", "$value": "56px", "$description": "Bottom tab-bar item height." }
|
||||
},
|
||||
"space": {
|
||||
"screen-pad-x": { "$type": "dimension", "$value": "16px", "$description": "Screen horizontal padding (vs desktop xl 20px)." },
|
||||
"card-gap": { "$type": "dimension", "$value": "10px", "$description": "Gap between cards in a list." }
|
||||
},
|
||||
"safe-area-bottom": { "$type": "dimension", "$value": "env(safe-area-inset-bottom)", "$description": "Not a static dimension — the sticky bottom nav and content bottom-padding must honor it. Kept as a CSS string." }
|
||||
},
|
||||
"_unmappable": {
|
||||
"$description": "Documented-but-not-tokenized: the page background is a layered radial-gradient ('radial-gradient(1200px 600px at 15% -10%, #1a3c5e44, transparent 60%), radial-gradient(1000px 500px at 90% 0%, #27496b33, transparent 58%), #0b1118') — see DESIGN.md §Depth/elevation."
|
||||
|
||||
Reference in New Issue
Block a user