docs: import TRM design handoff + defer adoption to phase 3.8

A design handoff bundle generated by Claude Design (claude.ai/design)
on 2026-05-02. Defines the Bloomberg/F1-pit-wall aesthetic for TRM:
- ink-on-paper base + race-flag red accent (#E8412B)
- square-edged everything, sharp printed offset shadows
- mono numerics (JetBrains Mono) for any changing value
- Goldplay (real licensed font, three weights in bundle fonts/)
- four surfaces designed: dashboard / leaderboard / mobile / marketing
  (SPA scope is the first two)

The bundle is committed in-tree at TRM_Design_System-handoff/ so 3.8
has the full source material when it picks the work up. Includes:
- Top-level + project READMEs (the design spec)
- chats/chat1.md (intent + iteration history)
- colors_and_type.css (token set, drop-in for Tailwind 4 @theme)
- fonts/ (Goldplay regular/semibold/bold)
- ui_kits/ (HTML prototypes per surface)
- preview/ (per-token visual reference cards)

Updated phase-3-dogfood-readiness/README.md task 3.8 row to point at
the bundle and document the recommended approach (retheme shadcn via
CSS variable overrides + Tailwind 4 @theme, not replace).

Why deferred: foundational tokens are non-blocking for Phase 1 (login
+ placeholder home) and Phase 2 (live map without chrome). Applying
them now would either delay dogfood-blocking work or land partial
styling that gets reworked when 3.8 lands the full pass.
This commit is contained in:
2026-05-02 19:10:11 +02:00
parent 0467a4b7ef
commit 8223a566e4
72 changed files with 3154 additions and 1 deletions
@@ -0,0 +1,23 @@
function AlertFeed({ alerts }) {
return (
<div className="card" style={{ height: '100%' }}>
<div className="card-head">
<h3>Race control feed</h3>
<span className="pill live" style={{ marginLeft: 'auto' }}><span className="dot"></span>LIVE</span>
</div>
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{alerts.map((a, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '4px 1fr auto', gap: 12, alignItems: 'flex-start', padding: '10px 14px', borderBottom: '1px solid var(--hairline)' }}>
<div style={{ width: 4, alignSelf: 'stretch', background: a.color }}></div>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 500 }}>{a.msg}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>{a.detail}</div>
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-4)' }}>{a.time}</div>
</div>
))}
</div>
</div>
);
}
window.AlertFeed = AlertFeed;
@@ -0,0 +1,42 @@
function EventsTable({ events, onSelect }) {
return (
<div className="card">
<div className="card-head">
<h3>Events</h3>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<button className="btn ghost"><i data-lucide="filter"></i>Filter</button>
<button className="btn primary"><i data-lucide="plus"></i>New event</button>
</div>
</div>
<table className="lb">
<thead>
<tr>
<th style={{ width: 30 }}></th>
<th>EVENT</th>
<th>DATE</th>
<th>SPORT</th>
<th className="num">STARTERS</th>
<th className="num">WAVES</th>
<th>STATUS</th>
<th></th>
</tr>
</thead>
<tbody>
{events.map(e => (
<tr key={e.id} onClick={() => onSelect && onSelect(e)} style={{ cursor: 'pointer' }}>
<td><i data-lucide={e.icon} style={{ width: 14, height: 14, color: 'var(--ink-3)' }}></i></td>
<td className="name">{e.name}</td>
<td>{e.date}</td>
<td>{e.sport}</td>
<td className="num">{e.starters.toLocaleString()}</td>
<td className="num">{e.waves}</td>
<td><span className={'pill ' + e.statusClass}><span className="dot"></span>{e.status}</span></td>
<td><i data-lucide="chevron-right" style={{ width: 14, height: 14, color: 'var(--ink-4)' }}></i></td>
</tr>
))}
</tbody>
</table>
</div>
);
}
window.EventsTable = EventsTable;
@@ -0,0 +1,16 @@
function KpiStrip({ items }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${items.length}, 1fr)`, gap: 0, border: '1px solid var(--ink)', background: 'var(--paper)' }}>
{items.map((it, i) => (
<div key={i} className="kpi" style={{ borderRight: i < items.length - 1 ? '1px solid var(--hairline)' : 'none' }}>
<div className="lbl">{it.label}</div>
<div className="val">{it.value}</div>
<div className={'delta ' + (it.dir || '')} style={{ color: it.dir === 'up' ? 'var(--green)' : it.dir === 'dn' ? 'var(--flag)' : 'var(--ink-4)' }}>
{it.delta}
</div>
</div>
))}
</div>
);
}
window.KpiStrip = KpiStrip;
@@ -0,0 +1,17 @@
# Dashboard UI Kit — TRM
Race director / timing official dashboard. Primary surface for organizing events, managing waves and bibs, and running race control on race day.
## Screens covered
- **Events overview** — list of upcoming/live/past events with KPI strip
- **Race control** — live event view with leaderboard, mat status, and alert feed
## Components
- `TopBar.jsx` — sticky header with brand, search, clock, user
- `SideNav.jsx` — primary navigation
- `EventsTable.jsx` — events list with status pills
- `KpiStrip.jsx` — 4-up KPI cards
- `RaceControl.jsx` — leaderboard + alert feed + mat status
- `AlertFeed.jsx` — race-control alerts
Open `index.html` to interact (click between screens).
@@ -0,0 +1,58 @@
function RaceControl({ leaders, alerts, mats }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: 16 }}>
<div className="card">
<div className="card-head">
<h3>Live leaderboard · Coastline 10K</h3>
<span className="pill live" style={{ marginLeft: 'auto' }}><span className="dot"></span>LIVE</span>
<span style={{ fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums', fontSize: 14, fontWeight: 600 }}>00:42:18.4</span>
</div>
<table className="lb">
<thead>
<tr>
<th style={{ width: 36 }}>POS</th>
<th style={{ width: 50 }}>BIB</th>
<th>NAME</th>
<th>WAVE</th>
<th className="num">SPLIT 3</th>
<th className="num">GAP</th>
<th className="num">PACE</th>
</tr>
</thead>
<tbody>
{leaders.map(l => (
<tr key={l.pos}>
<td style={{ fontWeight: 700, color: l.pos === 1 ? 'var(--flag)' : 'var(--ink)' }}>{l.pos}</td>
<td>{l.bib}</td>
<td className="name">{l.name}</td>
<td>{l.wave}</td>
<td className="num">{l.split}</td>
<td className={'num ' + (l.gap === '—' ? '' : 'loss')}>{l.gap}</td>
<td className="num">{l.pace}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="card">
<div className="card-head"><h3>Timing mats</h3></div>
<div style={{ padding: 14, display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
{mats.map(m => (
<div key={m.id} style={{ border: '1px solid var(--hairline)', padding: '8px 10px', display: 'flex', alignItems: 'center', gap: 10 }}>
<span className="dot" style={{ width: 8, height: 8, borderRadius: 999, background: m.online ? 'var(--green)' : 'var(--flag)', display: 'inline-block' }}></span>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 12, fontWeight: 600 }}>{m.name}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--ink-4)' }}>{m.id} · {m.km} km</div>
</div>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)' }}>{m.reads}</span>
</div>
))}
</div>
</div>
<AlertFeed alerts={alerts} />
</div>
</div>
);
}
window.RaceControl = RaceControl;
@@ -0,0 +1,39 @@
function SideNav({ active, onNav }) {
const items = [
{ id: 'events', icon: 'calendar', label: 'Events', badge: '12' },
{ id: 'live', icon: 'radio', label: 'Race control', badge: '1' },
{ id: 'results', icon: 'trophy', label: 'Results' },
{ id: 'participants', icon: 'users', label: 'Participants' },
{ id: 'mats', icon: 'route', label: 'Timing mats' },
{ id: 'reports', icon: 'bar-chart-3', label: 'Reports' },
];
const settings = [
{ id: 'team', icon: 'user-cog', label: 'Team' },
{ id: 'billing', icon: 'credit-card', label: 'Billing' },
{ id: 'settings', icon: 'settings', label: 'Settings' },
];
return (
<aside className="kit-side">
<div className="kit-side-section">Operations</div>
{items.map(it => (
<div key={it.id} className={'kit-nav-item' + (active === it.id ? ' active' : '')} onClick={() => onNav(it.id)}>
<i data-lucide={it.icon}></i>
<span>{it.label}</span>
{it.badge && <span className="badge">{it.badge}</span>}
</div>
))}
<div className="kit-side-section">Account</div>
{settings.map(it => (
<div key={it.id} className={'kit-nav-item' + (active === it.id ? ' active' : '')} onClick={() => onNav(it.id)}>
<i data-lucide={it.icon}></i>
<span>{it.label}</span>
</div>
))}
<div style={{ flex: 1 }}></div>
<div style={{ padding: 14, borderTop: '1px solid var(--hairline)', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--ink-4)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
v3.18.0 · build 2741
</div>
</aside>
);
}
window.SideNav = SideNav;
@@ -0,0 +1,22 @@
// TopBar — sticky header for the dashboard.
function TopBar({ clock }) {
return (
<header className="kit-top">
<div className="kit-brand">
<img src="../../assets/logo-trm.png" alt="TRM" />
<span className="b-meta">Dashboard</span>
</div>
<div className="kit-search">
<i data-lucide="search" style={{ width: 14, height: 14, color: 'var(--ink-3)' }}></i>
<input placeholder="Search events, bibs, athletes…" />
</div>
<div className="kit-top-actions">
<span className="pill live"><span className="dot"></span>LIVE · 1 EVENT</span>
<span className="kit-clk">{clock}</span>
<i data-lucide="bell" style={{ width: 18, height: 18 }}></i>
<div style={{ width: 28, height: 28, border: '1px solid var(--ink)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600 }}>JR</div>
</div>
</header>
);
}
window.TopBar = TopBar;
@@ -0,0 +1,128 @@
<!doctype html>
<html><head><meta charset="utf-8">
<title>TRM Dashboard · UI Kit</title>
<link rel="stylesheet" href="../../colors_and_type.css">
<link rel="stylesheet" href="../kit.css">
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="TopBar.jsx"></script>
<script type="text/babel" src="SideNav.jsx"></script>
<script type="text/babel" src="KpiStrip.jsx"></script>
<script type="text/babel" src="EventsTable.jsx"></script>
<script type="text/babel" src="AlertFeed.jsx"></script>
<script type="text/babel" src="RaceControl.jsx"></script>
<script type="text/babel">
const { useState, useEffect } = React;
const EVENTS = [
{ id: 1, name: 'Coastline 10K · Spring 2026', date: 'Apr 27, 09:00', sport: 'Run', starters: 1247, waves: 4, status: 'LIVE', statusClass: 'live', icon: 'radio' },
{ id: 2, name: 'Granite Crit Series · Round 3', date: 'May 03, 18:30', sport: 'Cycling', starters: 184, waves: 6, status: 'SCHEDULED', statusClass: 'info', icon: 'calendar' },
{ id: 3, name: 'Twin Peaks Triathlon', date: 'May 11, 06:30', sport: 'Triathlon', starters: 612, waves: 8, status: 'SCHEDULED', statusClass: 'info', icon: 'calendar' },
{ id: 4, name: 'Harbor Sprint Regatta', date: 'May 18, 11:00', sport: 'Sailing', starters: 88, waves: 3, status: 'DRAFT', statusClass: 'neutral', icon: 'edit' },
{ id: 5, name: 'Northshore Marathon', date: 'Apr 06, 07:30', sport: 'Run', starters: 3402, waves: 5, status: 'FINISHED', statusClass: 'ok', icon: 'check' },
{ id: 6, name: 'Iron Sands Ultra 100', date: 'Mar 22, 05:00', sport: 'Ultra', starters: 287, waves: 1, status: 'FINISHED', statusClass: 'ok', icon: 'check' },
];
const LEADERS = [
{ pos: 1, bib: 247, name: 'Maya Chen', wave: 'Elite', split: '00:42:18.4', gap: '—', pace: '04:13' },
{ pos: 2, bib: 188, name: 'Ravi Park', wave: 'Elite', split: '00:42:22.6', gap: '+0:04.2', pace: '04:14' },
{ pos: 3, bib: 44, name: 'Noemi Vega', wave: 'Elite', split: '00:42:30.1', gap: '+0:11.7', pace: '04:15' },
{ pos: 4, bib: 302, name: 'Tomás Riera', wave: 'Elite', split: '00:42:55.0', gap: '+0:36.6', pace: '04:18' },
{ pos: 5, bib: 119, name: 'Anika Joshi', wave: 'Elite', split: '00:43:01.2', gap: '+0:42.8', pace: '04:19' },
{ pos: 6, bib: 401, name: 'Felix Okafor', wave: 'Open', split: '00:43:08.7', gap: '+0:50.3', pace: '04:19' },
{ pos: 7, bib: 77, name: 'Sara Lindqvist',wave:'Open', split: '00:43:14.0', gap: '+0:55.6', pace: '04:20' },
{ pos: 8, bib: 215, name: 'David Müller', wave: 'Open', split: '00:43:22.4', gap: '+1:04.0', pace: '04:21' },
];
const ALERTS = [
{ msg: 'Bib 247 missed split 3', detail: 'Flagging for manual review', time: '14:02:11', color: 'var(--flag)' },
{ msg: 'Yellow flag · sector 2', detail: 'Debris cleared in 90s', time: '14:00:42', color: 'var(--amber)' },
{ msg: 'Wave 4 cleared start mat', detail: '412 / 412 starters', time: '13:45:00', color: 'var(--green)' },
{ msg: 'Tracking active', detail: '14 of 14 mats online', time: '13:30:00', color: 'var(--blue)' },
{ msg: 'Bib 088 retired', detail: 'Marked DNF · medical', time: '13:24:18', color: 'var(--ink-3)' },
];
const MATS = [
{ id: 'M-01', name: 'Start', km: 0, online: true, reads: '1,247' },
{ id: 'M-02', name: 'Split 1', km: 2.5, online: true, reads: '1,201' },
{ id: 'M-03', name: 'Split 2', km: 5.0, online: true, reads: '1,184' },
{ id: 'M-04', name: 'Split 3', km: 7.5, online: true, reads: '892' },
{ id: 'M-05', name: 'Finish', km: 10, online: true, reads: '0' },
{ id: 'M-06', name: 'Backup', km: 10, online: false, reads: '—' },
];
function App() {
const [active, setActive] = useState('events');
const [selectedEvent, setSelectedEvent] = useState(null);
const [clock, setClock] = useState('14:02:11');
useEffect(() => {
const id = setInterval(() => {
const d = new Date();
setClock(d.toTimeString().slice(0,8));
}, 1000);
return () => clearInterval(id);
}, []);
useEffect(() => { lucide.createIcons(); });
const showRaceControl = active === 'live' || (selectedEvent && selectedEvent.statusClass === 'live');
return (
<div className="kit-app">
<TopBar clock={clock} />
<div className="kit-main">
<SideNav active={showRaceControl ? 'live' : active} onNav={(id) => { setActive(id); setSelectedEvent(null); }} />
<main className="kit-content">
{showRaceControl ? (
<>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 14, marginBottom: 18 }}>
<button className="btn ghost" onClick={() => { setActive('events'); setSelectedEvent(null); }} style={{ padding: 0, height: 'auto', boxShadow: 'none', border: 0, color: 'var(--ink-3)', fontSize: 12 }}> All events</button>
<h1 style={{ fontSize: 28, margin: 0 }}>Coastline 10K · Spring 2026</h1>
<span className="pill live"><span className="dot"></span>LIVE</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<button className="btn"><i data-lucide="download"></i>Export</button>
<button className="btn flag"><i data-lucide="flag"></i>Issue red flag</button>
</div>
</div>
<div style={{ marginBottom: 16 }}>
<KpiStrip items={[
{ label: 'Active bibs', value: '1,247', delta: '↑ 12 in last 5 min', dir: 'up' },
{ label: 'Lead time', value: '42:18.4', delta: 'Bib 247 · split 3' },
{ label: 'Avg pace', value: '4:23/km', delta: '↓ 0:06 vs target', dir: 'dn' },
{ label: 'Mats online', value: '14 / 14', delta: '100% uptime', dir: 'up' },
]} />
</div>
<RaceControl leaders={LEADERS} alerts={ALERTS} mats={MATS} />
</>
) : (
<>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 14, marginBottom: 18 }}>
<h1 style={{ fontSize: 32, margin: 0 }}>Events</h1>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--ink-4)' }}>12 total · 1 live · 3 scheduled</span>
</div>
<div style={{ marginBottom: 16 }}>
<KpiStrip items={[
{ label: 'Events this season', value: '12', delta: '↑ 3 vs last season', dir: 'up' },
{ label: 'Total starters', value: '5,820', delta: '↑ 18% YoY', dir: 'up' },
{ label: 'Avg finish rate', value: '94.2%', delta: '↑ 1.4 pts', dir: 'up' },
{ label: 'Outstanding reviews',value: '7', delta: '3 high priority' },
]} />
</div>
<EventsTable events={EVENTS} onSelect={setSelectedEvent} />
</>
)}
</main>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body></html>
@@ -0,0 +1,67 @@
/* Shared chrome for all UI kits — top bar / sidebar / table tokens */
:root { --top-h: 56px; --side-w: 240px; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100vh; background: var(--paper); color: var(--ink); font-family: var(--font-body); }
.kit-app { display: grid; grid-template-rows: var(--top-h) 1fr; min-height: 100vh; }
.kit-top {
display: grid; grid-template-columns: var(--side-w) 1fr auto; align-items: center;
border-bottom: 1px solid var(--ink); background: var(--paper);
position: sticky; top: 0; z-index: 5;
}
.kit-brand { display: flex; align-items: center; gap: 10px; padding: 0 18px; height: 100%; border-right: 1px solid var(--ink); }
.kit-brand img { height: 22px; width: auto; }
.kit-brand .b-meta { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); letter-spacing: 0.08em; text-transform: uppercase; }
.kit-search { display: flex; align-items: center; gap: 10px; padding: 0 18px; height: 100%; }
.kit-search input { border: 1px solid var(--ink); height: 32px; width: 320px; padding: 0 10px; font-family: var(--font-mono); font-size: 13px; background: var(--paper-2); border-radius: 2px; }
.kit-top-actions { display: flex; align-items: center; gap: 14px; padding: 0 18px; height: 100%; border-left: 1px solid var(--hairline); }
.kit-clk { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 14px; font-weight: 600; }
.kit-main { display: grid; grid-template-columns: var(--side-w) 1fr; min-height: 0; }
.kit-side { border-right: 1px solid var(--ink); background: var(--paper); display: flex; flex-direction: column; }
.kit-side-section { padding: 16px 14px 6px; font-family: var(--font-display); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.10em; color: var(--ink-4); }
.kit-nav-item { display: flex; align-items: center; gap: 10px; padding: 0 14px; height: 36px; font-family: var(--font-display); font-size: 13px; color: var(--ink-2); cursor: pointer; border-left: 2px solid transparent; }
.kit-nav-item:hover { background: var(--paper-2); }
.kit-nav-item.active { background: var(--paper-2); border-left-color: var(--flag); color: var(--ink); font-weight: 600; }
.kit-nav-item [data-lucide] { width: 16px; height: 16px; stroke-width: 2; }
.kit-nav-item .badge { margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); }
.kit-content { padding: 22px 28px; min-width: 0; overflow: hidden; }
.btn { font-family: var(--font-display); font-weight: 600; font-size: 13px; border: 1px solid var(--ink); background: var(--paper); color: var(--ink); padding: 0 14px; height: 32px; cursor: pointer; display: inline-flex; align-items: center; gap: 8px; box-shadow: 2px 2px 0 0 var(--ink); border-radius: 0; transition: all 120ms var(--ease-snap); }
.btn:hover { box-shadow: 3px 3px 0 0 var(--ink); transform: translate(-1px,-1px); }
.btn:active { box-shadow: 0 0 0 0 var(--ink); transform: translate(2px,2px); }
.btn.primary { background: var(--ink); color: var(--paper); }
.btn.flag { background: var(--flag); color: #fff; }
.btn.ghost { box-shadow: none; }
.btn [data-lucide] { width: 14px; height: 14px; }
.pill { display:inline-flex; align-items:center; gap:6px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; border-radius: 999px; border: 1px solid currentColor; }
.pill .dot { width:6px; height:6px; border-radius:999px; background: currentColor; }
.pill.live { color: var(--flag); }
.pill.live .dot { animation: pulse 2s infinite ease-out; }
.pill.ok { color: var(--green); }
.pill.warn { color: var(--amber-2); }
.pill.danger { color: var(--flag-2); }
.pill.info { color: var(--blue-2); }
.pill.neutral { color: var(--ink-3); }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(232,65,43,0.6); } 100% { box-shadow: 0 0 0 8px rgba(232,65,43,0); } }
table.lb { width: 100%; border-collapse: collapse; border: 1px solid var(--ink); }
table.lb th, table.lb td { text-align: left; padding: 0 10px; height: 28px; font-family: var(--font-mono); font-size: 12px; border-bottom: 1px solid var(--hairline); }
table.lb th { background: var(--paper-2); font-weight: 600; font-size: 10px; letter-spacing: 0.10em; text-transform: uppercase; color: var(--ink-3); border-bottom: 1px solid var(--ink); }
table.lb tr:hover td { background: var(--paper-2); }
table.lb td.num { text-align: right; font-variant-numeric: tabular-nums; }
table.lb td.name { font-family: var(--font-display); font-weight: 500; font-size: 13px; }
.gain { color: var(--green); }
.loss { color: var(--flag); }
.card { border: 1px solid var(--ink); background: var(--paper); }
.card-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--ink); background: var(--paper-2); }
.card-head h3 { font-family: var(--font-display); font-size: 13px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; margin: 0; }
.card-body { padding: 14px; }
.kpi { padding: 14px; }
.kpi .lbl { font-family: var(--font-display); font-size: 10px; font-weight: 600; letter-spacing: 0.10em; text-transform: uppercase; color: var(--ink-3); }
.kpi .val { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 32px; font-weight: 600; letter-spacing: -0.02em; line-height: 1; margin-top: 8px; }
.kpi .delta { font-family: var(--font-mono); font-size: 11px; margin-top: 6px; }
@@ -0,0 +1,22 @@
function HeroClock({ time, eventName, sport, distance, weather }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', alignItems: 'center', padding: '24px 32px', borderBottom: '1px solid var(--night-line)' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<img src="../../assets/logo-trm.png" alt="TRM" style={{ height: 26, filter: 'invert(1) brightness(1.1)' }} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--night-fg-3)', letterSpacing: '0.10em', textTransform: 'uppercase' }}>Live tracking</span>
<span className="pill live"><span className="dot"></span>LIVE</span>
</div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 32, fontWeight: 700, marginTop: 14, letterSpacing: '-0.02em' }}>{eventName}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--night-fg-2)', marginTop: 8, letterSpacing: '0.04em' }}>
{sport} · {distance} · {weather}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--night-fg-3)', letterSpacing: '0.10em', textTransform: 'uppercase' }}>Race clock</div>
<div style={{ fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums', fontSize: 96, fontWeight: 600, letterSpacing: '-0.04em', lineHeight: 1, marginTop: 6 }}>{time}</div>
</div>
</div>
);
}
window.HeroClock = HeroClock;
@@ -0,0 +1,33 @@
function LiveTable({ rows }) {
return (
<table className="lb night">
<thead>
<tr>
<th style={{ width: 50 }}>POS</th>
<th style={{ width: 60 }}>BIB</th>
<th>NAME</th>
<th>WAVE</th>
<th className="num">SPLIT 3</th>
<th className="num">GAP TO LEAD</th>
<th className="num">PACE</th>
<th className="num">LAST</th>
</tr>
</thead>
<tbody>
{rows.map(r => (
<tr key={r.pos}>
<td style={{ fontWeight: 700, color: r.pos === 1 ? 'var(--flag)' : 'var(--night-fg)', fontSize: 14 }}>{r.pos}</td>
<td>{r.bib}</td>
<td className="name" style={{ color: 'var(--night-fg)' }}>{r.name}</td>
<td style={{ color: 'var(--night-fg-2)' }}>{r.wave}</td>
<td className="num">{r.split}</td>
<td className={'num ' + (r.gap === '—' ? '' : 'loss')}>{r.gap}</td>
<td className="num">{r.pace}</td>
<td className="num" style={{ color: 'var(--night-fg-3)' }}>{r.last}</td>
</tr>
))}
</tbody>
</table>
);
}
window.LiveTable = LiveTable;
@@ -0,0 +1,24 @@
function MiniMap({ checkpoints }) {
return (
<div className="card" style={{ background: 'var(--night-2)', borderColor: 'var(--night-line)', color: 'var(--night-fg)' }}>
<div className="card-head" style={{ background: 'var(--night-3)', borderColor: 'var(--night-line)' }}>
<h3 style={{ color: 'var(--night-fg)' }}>Course schematic</h3>
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--night-fg-3)' }}>10.0 km · 6 mats</span>
</div>
<div style={{ padding: 24, position: 'relative' }}>
<svg viewBox="0 0 600 160" style={{ width: '100%', height: 'auto' }}>
<path d="M 30 120 Q 120 30, 220 80 T 420 70 T 570 110" fill="none" stroke="var(--night-line)" strokeWidth="2" strokeDasharray="4 4" />
<path d="M 30 120 Q 120 30, 220 80 T 420 70" fill="none" stroke="var(--flag)" strokeWidth="3" />
{checkpoints.map((cp, i) => (
<g key={i}>
<circle cx={cp.x} cy={cp.y} r="6" fill={cp.passed ? 'var(--flag)' : 'var(--night-2)'} stroke={cp.passed ? 'var(--flag)' : 'var(--night-fg-3)'} strokeWidth="2" />
<text x={cp.x} y={cp.y - 14} textAnchor="middle" fontFamily="JetBrains Mono" fontSize="10" fill="var(--night-fg-2)">{cp.label}</text>
<text x={cp.x} y={cp.y + 22} textAnchor="middle" fontFamily="JetBrains Mono" fontSize="9" fill="var(--night-fg-3)">{cp.km} km</text>
</g>
))}
</svg>
</div>
</div>
);
}
window.MiniMap = MiniMap;
@@ -0,0 +1,12 @@
# Leaderboard UI Kit — TRM
Live spectator/broadcast leaderboard view. Optimized for night-mode kiosk and trackside displays. High-density, high-contrast, glanceable.
## Screens
- **Live leaderboard** with hero clock, race meta, full table, mini-map, last-passings ticker.
## Components
- `HeroClock.jsx` — jumbo race clock + meta strip
- `LiveTable.jsx` — full-width leaderboard
- `MiniMap.jsx` — schematic course with checkpoint dots
- `Ticker.jsx` — last passings strip across the bottom
@@ -0,0 +1,20 @@
function Ticker({ items }) {
return (
<div style={{ borderTop: '1px solid var(--night-line)', background: 'var(--night-2)', padding: '10px 0', overflow: 'hidden', whiteSpace: 'nowrap' }}>
<div style={{ display: 'inline-flex', gap: 32, animation: 'tickerScroll 40s linear infinite', paddingLeft: '100%' }}>
{[...items, ...items].map((it, i) => (
<span key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--night-fg-2)', display: 'inline-flex', gap: 8, alignItems: 'center' }}>
<span style={{ color: 'var(--flag)' }}></span>
<span style={{ color: 'var(--night-fg-3)' }}>{it.cp}</span>
<span style={{ color: 'var(--night-fg)', fontWeight: 600 }}>BIB {it.bib}</span>
<span style={{ color: 'var(--night-fg)' }}>{it.name}</span>
<span style={{ color: 'var(--night-fg-3)' }}>{it.time}</span>
<span style={{ color: it.delta.startsWith('-') ? 'var(--green)' : 'var(--flag)' }}>{it.delta}</span>
</span>
))}
</div>
<style>{`@keyframes tickerScroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }`}</style>
</div>
);
}
window.Ticker = Ticker;
@@ -0,0 +1,96 @@
<!doctype html>
<html><head><meta charset="utf-8">
<title>TRM Live Leaderboard</title>
<link rel="stylesheet" href="../../colors_and_type.css">
<link rel="stylesheet" href="../kit.css">
<style>
body { background: var(--night); color: var(--night-fg); }
.pill.live { color: var(--flag); border-color: var(--flag); }
table.lb.night { border-color: var(--night-line); }
table.lb.night th { background: var(--night-3); color: var(--night-fg-3); border-bottom-color: var(--night-line); }
table.lb.night td { border-bottom-color: var(--night-line); color: var(--night-fg); font-size: 14px; height: 36px; }
table.lb.night tr:hover td { background: var(--night-3); }
.loss { color: #ff8a78 !important; }
.card { background: var(--night-2); border-color: var(--night-line); color: var(--night-fg); }
.card-head { background: var(--night-3); border-bottom-color: var(--night-line); }
.card-head h3 { color: var(--night-fg); }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="HeroClock.jsx"></script>
<script type="text/babel" src="LiveTable.jsx"></script>
<script type="text/babel" src="MiniMap.jsx"></script>
<script type="text/babel" src="Ticker.jsx"></script>
<script type="text/babel">
const { useState, useEffect } = React;
const ROWS = [
{ pos: 1, bib: 247, name: 'Maya Chen', wave: 'Elite', split: '00:42:18.4', gap: '—', pace: '04:13', last: '0:18 ago' },
{ pos: 2, bib: 188, name: 'Ravi Park', wave: 'Elite', split: '00:42:22.6', gap: '+0:04.2', pace: '04:14', last: '0:22 ago' },
{ pos: 3, bib: 44, name: 'Noemi Vega', wave: 'Elite', split: '00:42:30.1', gap: '+0:11.7', pace: '04:15', last: '0:30 ago' },
{ pos: 4, bib: 302, name: 'Tomás Riera', wave: 'Elite', split: '00:42:55.0', gap: '+0:36.6', pace: '04:18', last: '0:55 ago' },
{ pos: 5, bib: 119, name: 'Anika Joshi', wave: 'Elite', split: '00:43:01.2', gap: '+0:42.8', pace: '04:19', last: '1:01 ago' },
{ pos: 6, bib: 401, name: 'Felix Okafor', wave: 'Open', split: '00:43:08.7', gap: '+0:50.3', pace: '04:19', last: '1:08 ago' },
{ pos: 7, bib: 77, name: 'Sara Lindqvist',wave: 'Open', split: '00:43:14.0', gap: '+0:55.6', pace: '04:20', last: '1:14 ago' },
{ pos: 8, bib: 215, name: 'David Müller', wave: 'Open', split: '00:43:22.4', gap: '+1:04.0', pace: '04:21', last: '1:22 ago' },
{ pos: 9, bib: 503, name: 'Inés Báez', wave: 'Open', split: '00:43:30.6', gap: '+1:12.2', pace: '04:21', last: '1:30 ago' },
{ pos:10, bib: 162, name: 'Kenji Sato', wave: 'Open', split: '00:43:38.0', gap: '+1:19.6', pace: '04:22', last: '1:38 ago' },
];
const CHECKPOINTS = [
{ label: 'START', km: 0, x: 30, y: 120, passed: true },
{ label: 'CP1', km: 2.5, x: 150, y: 60, passed: true },
{ label: 'CP2', km: 5.0, x: 270, y: 80, passed: true },
{ label: 'CP3', km: 7.5, x: 380, y: 70, passed: true },
{ label: 'CP4', km: 9.0, x: 480, y: 90, passed: false },
{ label: 'FINISH', km: 10, x: 570, y: 110, passed: false },
];
const TICKER = [
{ cp: 'CP3', bib: 247, name: 'M. Chen', time: '00:42:18.4', delta: '-0:01.6' },
{ cp: 'CP3', bib: 188, name: 'R. Park', time: '00:42:22.6', delta: '+0:00.4' },
{ cp: 'CP3', bib: 44, name: 'N. Vega', time: '00:42:30.1', delta: '+0:01.9' },
{ cp: 'CP3', bib: 302, name: 'T. Riera', time: '00:42:55.0', delta: '+0:04.4' },
{ cp: 'CP3', bib: 119, name: 'A. Joshi', time: '00:43:01.2', delta: '-0:02.1' },
{ cp: 'CP3', bib: 401, name: 'F. Okafor', time: '00:43:08.7', delta: '+0:00.6' },
];
function App() {
const [t, setT] = useState({ h: 1, m: 23, s: 45, ms: 6 });
useEffect(() => {
const id = setInterval(() => {
setT(p => {
let ms = p.ms + 1;
let s = p.s, m = p.m, h = p.h;
if (ms > 9) { ms = 0; s++; }
if (s > 59) { s = 0; m++; }
if (m > 59) { m = 0; h++; }
return { h, m, s, ms };
});
}, 100);
return () => clearInterval(id);
}, []);
const pad = n => String(n).padStart(2, '0');
const time = `${pad(t.h)}:${pad(t.m)}:${pad(t.s)}.${t.ms}`;
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
<HeroClock time={time} eventName="Coastline 10K · Spring 2026" sport="Run · 10K" distance="Coastal route, flat" weather="18°C · light SW · dry" />
<div style={{ flex: 1, padding: '24px 32px', display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 24, minHeight: 0 }}>
<div style={{ minHeight: 0, overflow: 'hidden' }}>
<LiveTable rows={ROWS} />
</div>
<MiniMap checkpoints={CHECKPOINTS} />
</div>
<Ticker items={TICKER} />
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body></html>
@@ -0,0 +1,28 @@
function FeatureGrid() {
const features = [
{ title: 'Chip-time accuracy', body: 'Sub-100ms read precision across UHF and BLE timing mats. One mat, one truth.', stat: '<100ms' },
{ title: 'Any sport', body: 'Run, ride, swim, paddle, sail, ski, drive. Configure laps, splits, transitions per discipline.', stat: '14 sports' },
{ title: 'Live everywhere', body: 'Embed leaderboards anywhere. Push splits to athletes and spectators in real time.', stat: 'Real-time' },
{ title: 'Race control', body: 'Yellow flag, red flag, manual overrides, DSQ workflows. Race directors stay in command.', stat: 'In-app' },
{ title: 'Results in minutes', body: 'Auto-generated category results, exportable to USATF / UCI / FFC formats.', stat: '<2 min' },
{ title: 'Athlete companion', body: 'Free mobile app for participants and supporters. Branded for your event, no extra cost.', stat: 'iOS · Android' },
];
return (
<section style={{ borderBottom: '1px solid var(--ink)' }}>
<div style={{ padding: '64px 32px 32px' }}>
<div className="overline">What's in TRM</div>
<h2 style={{ fontSize: 44, margin: '12px 0 0', maxWidth: 720, letterSpacing: '-0.02em', lineHeight: 1.05 }}>The whole stack — from start mat to certificate.</h2>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', borderTop: '1px solid var(--ink)' }}>
{features.map((f, i) => (
<div key={i} style={{ padding: 28, borderRight: i % 3 < 2 ? '1px solid var(--ink)' : 'none', borderBottom: i < 3 ? '1px solid var(--ink)' : 'none' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 32, fontWeight: 700, letterSpacing: '-0.02em', color: 'var(--flag)' }}>{f.stat}</div>
<h3 style={{ fontSize: 20, margin: '16px 0 8px', fontFamily: 'var(--font-display)', fontWeight: 600 }}>{f.title}</h3>
<p style={{ fontFamily: 'var(--font-body)', fontSize: 14, lineHeight: 1.55, color: 'var(--ink-2)', margin: 0 }}>{f.body}</p>
</div>
))}
</div>
</section>
);
}
window.FeatureGrid = FeatureGrid;
@@ -0,0 +1,14 @@
function LogoWall() {
const orgs = ['NORTHSHORE MARATHON', 'IRON SANDS ULTRA', 'GRANITE CRIT', 'TWIN PEAKS TRI', 'HARBOR REGATTA', 'SUMMIT VERTICAL', 'MIDNIGHT 24', 'COASTLINE 10K'];
return (
<section style={{ borderBottom: '1px solid var(--ink)', padding: '40px 32px' }}>
<div className="overline" style={{ textAlign: 'center', marginBottom: 24 }}>Trusted by event organizers in 38 countries</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 0, border: '1px solid var(--hairline)' }}>
{orgs.map((o, i) => (
<div key={i} style={{ padding: '22px 16px', textAlign: 'center', borderRight: i % 4 < 3 ? '1px solid var(--hairline)' : 'none', borderBottom: i < 4 ? '1px solid var(--hairline)' : 'none', fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, letterSpacing: '0.10em', color: 'var(--ink-3)' }}>{o}</div>
))}
</div>
</section>
);
}
window.LogoWall = LogoWall;
@@ -0,0 +1,33 @@
function MktFooter() {
const cols = [
{ h: 'Product', l: ['Features', 'Sports', 'Hardware', 'Pricing', 'Changelog'] },
{ h: 'Solutions', l: ['Running', 'Cycling', 'Triathlon', 'Sailing', 'Federation'] },
{ h: 'Resources', l: ['Docs', 'API', 'Support', 'Status', 'Help center'] },
{ h: 'Company', l: ['About', 'Customers', 'Careers', 'Press', 'Contact'] },
];
return (
<footer style={{ background: 'var(--ink)', color: 'var(--paper)', padding: '56px 32px 24px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr repeat(4, 1fr)', gap: 32 }}>
<div>
<img src="../../assets/logo-trm.png" alt="TRM" style={{ height: 30, filter: 'invert(1) brightness(1.1)' }} />
<p style={{ fontFamily: 'var(--font-body)', fontSize: 13, lineHeight: 1.55, marginTop: 14, color: 'var(--paper-3)', maxWidth: 280 }}>
The race-day operating system. Built by timing officials, for timing officials.
</p>
</div>
{cols.map(c => (
<div key={c.h}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 11, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase', color: 'var(--ink-5)' }}>{c.h}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 }}>
{c.l.map(x => <a key={x} href="#" style={{ fontFamily: 'var(--font-display)', fontSize: 13, color: 'var(--paper)', textDecoration: 'none' }}>{x}</a>)}
</div>
</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48, paddingTop: 18, borderTop: '1px solid rgba(255,255,255,0.1)', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-5)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
<div>© 2026 TRM Tracking & Race Management</div>
<div>v3.18.0 · 99.99% uptime · 14:02:11 UTC</div>
</div>
</footer>
);
}
window.MktFooter = MktFooter;
@@ -0,0 +1,70 @@
function MktHero() {
return (
<section style={{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: 0, borderBottom: '1px solid var(--ink)' }}>
<div style={{ padding: '80px 56px 80px 32px', borderRight: '1px solid var(--ink)' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 20 }}>
<span className="pill live"><span className="dot"></span>LIVE NOW · 47 EVENTS</span>
</div>
<h1 style={{ fontSize: 76, lineHeight: 0.98, letterSpacing: '-0.03em', fontWeight: 700, margin: 0 }}>
Run any event.<br/>
<span style={{ color: 'var(--flag)' }}>Time</span> any sport.
</h1>
<p style={{ fontFamily: 'var(--font-body)', fontSize: 18, lineHeight: 1.55, marginTop: 24, maxWidth: 520, color: 'var(--ink-2)' }}>
TRM is the race-day operating system used by 4,000+ event organizers from 5K fun runs to 24-hour endurance races. One platform for timing, tracking, and results.
</p>
<div style={{ display: 'flex', gap: 12, marginTop: 32 }}>
<button className="btn primary" style={{ height: 44, padding: '0 22px', fontSize: 15 }}>Start free trial</button>
<button className="btn" style={{ height: 44, padding: '0 22px', fontSize: 15 }}>Book a demo</button>
</div>
<div style={{ marginTop: 40, display: 'flex', gap: 32 }}>
<div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em' }}>4,000+</div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 11, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase', color: 'var(--ink-3)', marginTop: 4 }}>Organizers</div>
</div>
<div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em' }}>2.1M</div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 11, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase', color: 'var(--ink-3)', marginTop: 4 }}>Athletes timed</div>
</div>
<div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em' }}>14</div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 11, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase', color: 'var(--ink-3)', marginTop: 4 }}>Sports supported</div>
</div>
</div>
</div>
<div style={{ background: 'var(--night)', color: 'var(--night-fg)', padding: 32, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span className="pill live" style={{ color: 'var(--flag)' }}><span className="dot"></span>LIVE</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--night-fg-3)', letterSpacing: '0.10em', textTransform: 'uppercase' }}>Coastline 10K · Spring 2026</span>
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums', fontSize: 56, fontWeight: 600, letterSpacing: '-0.04em', lineHeight: 1, marginTop: 16 }}>01:23:45.6</div>
<div style={{ marginTop: 24, border: '1px solid var(--night-line)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--night-3)' }}>
<th style={{ textAlign: 'left', padding: '8px 12px', fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase', color: 'var(--night-fg-3)', borderBottom: '1px solid var(--night-line)' }}>POS</th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase', color: 'var(--night-fg-3)', borderBottom: '1px solid var(--night-line)' }}>NAME</th>
<th style={{ textAlign: 'right', padding: '8px 12px', fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase', color: 'var(--night-fg-3)', borderBottom: '1px solid var(--night-line)' }}>GAP</th>
</tr>
</thead>
<tbody>
{[
{ p: 1, n: 'Maya Chen', g: '—' },
{ p: 2, n: 'Ravi Park', g: '+0:04.2' },
{ p: 3, n: 'Noemi Vega', g: '+0:11.7' },
{ p: 4, n: 'Tomás Riera', g: '+0:36.6' },
].map((r, i) => (
<tr key={i}>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 700, color: r.p === 1 ? 'var(--flag)' : 'var(--night-fg)', borderBottom: '1px solid var(--night-line)' }}>{r.p}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 500, borderBottom: '1px solid var(--night-line)' }}>{r.n}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', fontSize: 13, textAlign: 'right', color: r.g === '—' ? 'var(--night-fg)' : '#ff8a78', borderBottom: '1px solid var(--night-line)' }}>{r.g}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--night-fg-3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginTop: 14 }}>Embed any leaderboard on your event site</div>
</div>
</section>
);
}
window.MktHero = MktHero;
@@ -0,0 +1,20 @@
function MktNav() {
const items = ['Product', 'Sports', 'Pricing', 'Customers', 'Docs'];
return (
<nav style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', padding: '18px 32px', borderBottom: '1px solid var(--ink)', position: 'sticky', top: 0, background: 'var(--paper)', zIndex: 5 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img src="../../assets/logo-trm.png" alt="TRM" style={{ height: 24 }} />
</div>
<div style={{ display: 'flex', gap: 28, justifyContent: 'center' }}>
{items.map(i => (
<a key={i} href="#" style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 500, textDecoration: 'none', color: 'var(--ink)' }}>{i}</a>
))}
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', alignItems: 'center' }}>
<a href="#" style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 500, textDecoration: 'none', color: 'var(--ink)' }}>Sign in</a>
<button className="btn primary">Start free trial</button>
</div>
</nav>
);
}
window.MktNav = MktNav;
@@ -0,0 +1,29 @@
function PricingStrip() {
const tiers = [
{ name: 'Club', price: '$0', per: 'per event', desc: 'Up to 200 starters · self-service timing.', cta: 'Start free', primary: false },
{ name: 'Organizer', price: '$2', per: 'per starter', desc: 'Unlimited events · race control · live leaderboards.', cta: 'Start trial', primary: true },
{ name: 'Federation', price: 'Custom', per: 'annual', desc: 'White-label · API · SLA · dedicated support.', cta: 'Contact sales', primary: false },
];
return (
<section style={{ borderBottom: '1px solid var(--ink)' }}>
<div style={{ padding: '64px 32px 32px' }}>
<div className="overline">Pricing</div>
<h2 style={{ fontSize: 44, margin: '12px 0 0', letterSpacing: '-0.02em', lineHeight: 1.05 }}>Pay per starter. No setup fees.</h2>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', borderTop: '1px solid var(--ink)' }}>
{tiers.map((t, i) => (
<div key={i} style={{ padding: 32, borderRight: i < 2 ? '1px solid var(--ink)' : 'none', background: t.primary ? 'var(--ink)' : 'var(--paper)', color: t.primary ? 'var(--paper)' : 'var(--ink)' }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 11, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase', color: t.primary ? 'var(--paper-3)' : 'var(--ink-3)' }}>{t.name}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginTop: 16 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 56, fontWeight: 700, letterSpacing: '-0.03em' }}>{t.price}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: t.primary ? 'var(--paper-3)' : 'var(--ink-3)' }}>{t.per}</span>
</div>
<p style={{ fontFamily: 'var(--font-body)', fontSize: 14, lineHeight: 1.55, marginTop: 16, color: t.primary ? 'var(--paper-3)' : 'var(--ink-2)' }}>{t.desc}</p>
<button className="btn" style={{ marginTop: 20, height: 40, background: t.primary ? 'var(--flag)' : 'var(--paper)', color: t.primary ? '#fff' : 'var(--ink)', borderColor: t.primary ? 'var(--paper)' : 'var(--ink)', boxShadow: t.primary ? '2px 2px 0 0 var(--paper)' : '2px 2px 0 0 var(--ink)' }}>{t.cta}</button>
</div>
))}
</div>
</section>
);
}
window.PricingStrip = PricingStrip;
@@ -0,0 +1,19 @@
# Marketing Site UI Kit — TRM
Single-page marketing site for race organizers evaluating the platform.
## Sections
- Top nav with brand
- Hero with split layout — bold headline + live data peek
- Logo wall (event organizers using TRM)
- Feature grid
- Pricing strip
- Footer
## Components
- `MktNav.jsx`
- `MktHero.jsx`
- `LogoWall.jsx`
- `FeatureGrid.jsx`
- `PricingStrip.jsx`
- `MktFooter.jsx`
@@ -0,0 +1,38 @@
<!doctype html>
<html><head><meta charset="utf-8">
<title>TRM Marketing</title>
<link rel="stylesheet" href="../../colors_and_type.css">
<link rel="stylesheet" href="../kit.css">
<style>
body { background: var(--paper); }
a { text-decoration: none; }
a:hover { color: var(--flag); }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="MktNav.jsx"></script>
<script type="text/babel" src="MktHero.jsx"></script>
<script type="text/babel" src="LogoWall.jsx"></script>
<script type="text/babel" src="FeatureGrid.jsx"></script>
<script type="text/babel" src="PricingStrip.jsx"></script>
<script type="text/babel" src="MktFooter.jsx"></script>
<script type="text/babel">
function App() {
return (
<div>
<MktNav />
<MktHero />
<LogoWall />
<FeatureGrid />
<PricingStrip />
<MktFooter />
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body></html>
@@ -0,0 +1,26 @@
function BibCard({ bib, name, event, projected, status }) {
return (
<div style={{ border: '1px solid var(--ink)', padding: 18, background: 'var(--paper)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="pill live" style={{ fontSize: 9 }}><span className="dot"></span>LIVE</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)', letterSpacing: '0.08em', textTransform: 'uppercase' }}>{event}</span>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginTop: 14 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-4)', letterSpacing: '0.10em', textTransform: 'uppercase' }}>BIB</span>
<span style={{ fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums', fontSize: 56, fontWeight: 700, lineHeight: 1, letterSpacing: '-0.03em' }}>{bib}</span>
</div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, marginTop: 6 }}>{name}</div>
<div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--hairline)', display: 'flex', justifyContent: 'space-between' }}>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 10, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase', color: 'var(--ink-3)' }}>Projected finish</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 22, fontWeight: 600, marginTop: 4 }}>{projected}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 10, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase', color: 'var(--ink-3)' }}>Status</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 14, color: 'var(--green)', marginTop: 6, fontWeight: 600 }}>{status}</div>
</div>
</div>
</div>
);
}
window.BibCard = BibCard;
@@ -0,0 +1,21 @@
function BottomNav({ active, onNav }) {
const items = [
{ id: 'race', label: 'My race', icon: 'M12 2v4 M12 18v4 M2 12h4 M18 12h4' },
{ id: 'follow',label: 'Following', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8 M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75' },
{ id: 'find', label: 'Find', icon: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16 M21 21l-4.35-4.35' },
{ id: 'me', label: 'Profile', icon: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2 M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8' },
];
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', borderTop: '1px solid var(--ink)', background: 'var(--paper)' }}>
{items.map(it => (
<div key={it.id} onClick={() => onNav(it.id)} style={{ padding: '10px 0 6px', textAlign: 'center', cursor: 'pointer', borderTop: active === it.id ? '2px solid var(--flag)' : '2px solid transparent', marginTop: -1 }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={active === it.id ? 'var(--flag)' : 'var(--ink-3)'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
{it.icon.split(' M').map((d, i) => <path key={i} d={(i === 0 ? '' : 'M') + d} />)}
</svg>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', marginTop: 4, color: active === it.id ? 'var(--flag)' : 'var(--ink-3)' }}>{it.label}</div>
</div>
))}
</div>
);
}
window.BottomNav = BottomNav;
@@ -0,0 +1,24 @@
function FollowList({ people, onSelect }) {
return (
<div style={{ border: '1px solid var(--ink)', background: 'var(--paper)' }}>
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--ink)', background: 'var(--paper-2)', display: 'flex', alignItems: 'center' }}>
<span style={{ fontFamily: 'var(--font-display)', fontSize: 11, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase' }}>Following</span>
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)' }}>{people.length}</span>
</div>
{people.map((p, i) => (
<div key={i} onClick={() => onSelect && onSelect(p)} style={{ display: 'grid', gridTemplateColumns: '40px 1fr auto', gap: 12, padding: '12px 14px', borderBottom: i < people.length - 1 ? '1px solid var(--hairline)' : 'none', alignItems: 'center', cursor: 'pointer' }}>
<div style={{ width: 36, height: 36, border: '1px solid var(--ink)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700 }}>{p.bib}</div>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 600 }}>{p.name}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>{p.event} · {p.lastSplit}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600 }}>{p.time}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: p.delta && p.delta.startsWith('-') ? 'var(--green)' : 'var(--flag)', marginTop: 2 }}>{p.delta}</div>
</div>
</div>
))}
</div>
);
}
window.FollowList = FollowList;
@@ -0,0 +1,13 @@
# Mobile UI Kit — TRM
Athlete & spectator mobile companion. Inside an iOS frame for context.
## Screens
- **My race** — athlete view: bib, current split, projected finish, course progress
- **Following** — spectator view: list of tracked athletes with live status
## Components
- `BibCard.jsx` — hero bib + projected finish
- `SplitList.jsx` — vertical timeline of splits
- `FollowList.jsx` — list of followed athletes
- `BottomNav.jsx` — tab bar
@@ -0,0 +1,22 @@
function SplitList({ splits }) {
return (
<div style={{ border: '1px solid var(--ink)', background: 'var(--paper)' }}>
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--ink)', background: 'var(--paper-2)', display: 'flex', alignItems: 'center' }}>
<span style={{ fontFamily: 'var(--font-display)', fontSize: 11, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase' }}>Splits</span>
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)' }}>4 of 6</span>
</div>
{splits.map((s, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '24px 1fr auto auto', gap: 12, padding: '10px 14px', borderBottom: i < splits.length - 1 ? '1px solid var(--hairline)' : 'none', alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: 999, background: s.passed ? 'var(--flag)' : 'var(--paper)', border: '2px solid ' + (s.passed ? 'var(--flag)' : 'var(--ink-5)'), justifySelf: 'center' }}></span>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 600 }}>{s.name}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--ink-4)', marginTop: 2 }}>{s.km} km</div>
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, color: s.passed ? 'var(--ink)' : 'var(--ink-4)' }}>{s.time}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: s.passed ? (s.delta.startsWith('-') ? 'var(--green)' : 'var(--flag)') : 'var(--ink-4)' }}>{s.delta}</div>
</div>
))}
</div>
);
}
window.SplitList = SplitList;
@@ -0,0 +1,84 @@
<!doctype html>
<html><head><meta charset="utf-8">
<title>TRM Mobile · UI Kit</title>
<link rel="stylesheet" href="../../colors_and_type.css">
<link rel="stylesheet" href="../kit.css">
<style>
body { background: var(--paper-3); display: flex; align-items: center; justify-content: center; padding: 32px; min-height: 100vh; }
.stage { display: flex; gap: 32px; align-items: flex-start; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="ios-frame.jsx"></script>
<script type="text/babel" src="BibCard.jsx"></script>
<script type="text/babel" src="SplitList.jsx"></script>
<script type="text/babel" src="FollowList.jsx"></script>
<script type="text/babel" src="BottomNav.jsx"></script>
<script type="text/babel">
const { useState } = React;
const SPLITS = [
{ name: 'Start', km: 0, time: '09:00:00.0', delta: '—', passed: true },
{ name: 'Split 1', km: 2.5, time: '00:10:38.4', delta: '-0:01.2', passed: true },
{ name: 'Split 2', km: 5.0, time: '00:21:14.7', delta: '-0:02.0', passed: true },
{ name: 'Split 3', km: 7.5, time: '00:31:55.1', delta: '+0:00.4', passed: true },
{ name: 'Split 4', km: 9.0, time: '—', delta: '—', passed: false },
{ name: 'Finish', km: 10, time: '—', delta: '—', passed: false },
];
const PEOPLE = [
{ bib: 247, name: 'Maya Chen', event: 'Coastline 10K', lastSplit: 'Split 3', time: '31:55.1', delta: '+0:00.4' },
{ bib: 188, name: 'Ravi Park', event: 'Coastline 10K', lastSplit: 'Split 3', time: '32:01.6', delta: '-0:01.8' },
{ bib: 44, name: 'Noemi Vega', event: 'Coastline 10K', lastSplit: 'Split 3', time: '32:08.4', delta: '+0:02.1' },
{ bib: 612, name: 'Diego Acosta', event: 'Twin Peaks Tri', lastSplit: 'Bike', time: '1:12:04', delta: '-0:18.0' },
];
function MyRace() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, padding: 14 }}>
<BibCard bib="247" name="Maya Chen" event="Coastline 10K · Spring 2026" projected="00:42:12" status="ON PACE · PB" />
<SplitList splits={SPLITS} />
</div>
);
}
function Following({ onSelect }) {
return (
<div style={{ padding: 14 }}>
<FollowList people={PEOPLE} onSelect={onSelect} />
</div>
);
}
function App() {
const [tab, setTab] = useState('race');
return (
<div className="stage">
<IOSDevice title="My race" width={390} height={780}>
<div style={{ background: 'var(--paper-2)', minHeight: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, overflow: 'auto' }}>
{tab === 'race' && <MyRace />}
{tab === 'follow' && <Following onSelect={() => setTab('race')} />}
{tab === 'find' && <div style={{ padding: 24, textAlign: 'center', color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>Search any event by name or bib.</div>}
{tab === 'me' && <div style={{ padding: 24, textAlign: 'center', color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>Profile · history · personal bests</div>}
</div>
<BottomNav active={tab} onNav={setTab} />
</div>
</IOSDevice>
<IOSDevice title="Following" width={390} height={780}>
<div style={{ background: 'var(--paper-2)', minHeight: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, overflow: 'auto' }}><Following /></div>
<BottomNav active="follow" onNav={() => {}} />
</div>
</IOSDevice>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body></html>
@@ -0,0 +1,338 @@
// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame
// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps.
// Exports: IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard
// ─────────────────────────────────────────────────────────────
// Status bar
// ─────────────────────────────────────────────────────────────
function IOSStatusBar({ dark = false, time = '9:41' }) {
const c = dark ? '#fff' : '#000';
return (
<div style={{
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center',
padding: '21px 24px 19px', boxSizing: 'border-box',
position: 'relative', zIndex: 20, width: '100%',
}}>
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}>
<span style={{
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590,
fontSize: 17, lineHeight: '22px', color: c,
}}>{time}</span>
</div>
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}>
<svg width="19" height="12" viewBox="0 0 19 12">
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/>
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/>
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/>
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/>
</svg>
<svg width="17" height="12" viewBox="0 0 17 12">
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/>
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/>
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/>
</svg>
<svg width="27" height="13" viewBox="0 0 27 13">
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/>
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/>
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/>
</svg>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Liquid glass pill — blur + tint + shine
// ─────────────────────────────────────────────────────────────
function IOSGlassPill({ children, dark = false, style = {} }) {
return (
<div style={{
height: 44, minWidth: 44, borderRadius: 9999,
position: 'relative', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: dark
? '0 2px 6px rgba(0,0,0,0.35), 0 6px 16px rgba(0,0,0,0.2)'
: '0 1px 3px rgba(0,0,0,0.07), 0 3px 10px rgba(0,0,0,0.06)',
...style,
}}>
{/* blur + tint */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 9999,
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
background: dark ? 'rgba(120,120,128,0.28)' : 'rgba(255,255,255,0.5)',
}} />
{/* shine */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 9999,
boxShadow: dark
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15), inset -1px -1px 1px rgba(255,255,255,0.08)'
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
}} />
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', padding: '0 4px' }}>
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Navigation bar — glass pills + large title
// ─────────────────────────────────────────────────────────────
function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) {
const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040';
const text = dark ? '#fff' : '#000';
const pillIcon = (content) => (
<IOSGlassPill dark={dark}>
<div style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{content}
</div>
</IOSGlassPill>
);
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 10,
paddingTop: 62, paddingBottom: 10, position: 'relative', zIndex: 5,
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 16px',
}}>
{/* back chevron */}
{pillIcon(
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" style={{ marginLeft: -1 }}>
<path d="M10 2L2 10l8 8" stroke={muted} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{/* trailing ellipsis */}
{trailingIcon && pillIcon(
<svg width="22" height="6" viewBox="0 0 22 6">
<circle cx="3" cy="3" r="2.5" fill={muted}/>
<circle cx="11" cy="3" r="2.5" fill={muted}/>
<circle cx="19" cy="3" r="2.5" fill={muted}/>
</svg>
)}
</div>
{/* large title */}
<div style={{
padding: '0 16px',
fontFamily: '-apple-system, system-ui',
fontSize: 34, fontWeight: 700, lineHeight: '41px',
color: text, letterSpacing: 0.4,
}}>{title}</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Grouped list (inset card, r:26) + row (52px)
// ─────────────────────────────────────────────────────────────
function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) {
const text = dark ? '#fff' : '#000';
const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)';
const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)';
return (
<div style={{
display: 'flex', alignItems: 'center', minHeight: 52,
padding: '0 16px', position: 'relative',
fontFamily: '-apple-system, system-ui', fontSize: 17,
letterSpacing: -0.43,
}}>
{icon && (
<div style={{
width: 30, height: 30, borderRadius: 7, background: icon,
marginRight: 12, flexShrink: 0,
}} />
)}
<div style={{ flex: 1, color: text }}>{title}</div>
{detail && <span style={{ color: sec, marginRight: 6 }}>{detail}</span>}
{chevron && (
<svg width="8" height="14" viewBox="0 0 8 14" style={{ flexShrink: 0 }}>
<path d="M1 1l6 6-6 6" stroke={ter} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{!isLast && (
<div style={{
position: 'absolute', bottom: 0, right: 0,
left: icon ? 58 : 16, height: 0.5, background: sep,
}} />
)}
</div>
);
}
function IOSList({ header, children, dark = false }) {
const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
const bg = dark ? '#1C1C1E' : '#fff';
return (
<div>
{header && (
<div style={{
fontFamily: '-apple-system, system-ui', fontSize: 13,
color: hc, textTransform: 'uppercase',
padding: '8px 36px 6px', letterSpacing: -0.08,
}}>{header}</div>
)}
<div style={{
background: bg, borderRadius: 26,
margin: '0 16px', overflow: 'hidden',
}}>{children}</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Device frame
// ─────────────────────────────────────────────────────────────
function IOSDevice({
children, width = 402, height = 874, dark = false,
title, keyboard = false,
}) {
return (
<div style={{
width, height, borderRadius: 48, overflow: 'hidden',
position: 'relative', background: dark ? '#000' : '#F2F2F7',
boxShadow: '0 40px 80px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.12)',
fontFamily: '-apple-system, system-ui, sans-serif',
WebkitFontSmoothing: 'antialiased',
}}>
{/* dynamic island */}
<div style={{
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)',
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50,
}} />
{/* status bar (absolute) */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
<IOSStatusBar dark={dark} />
</div>
{/* nav + content */}
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{title !== undefined && <IOSNavBar title={title} dark={dark} />}
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
{keyboard && <IOSKeyboard dark={dark} />}
</div>
{/* home indicator — always on top */}
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60,
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end',
paddingBottom: 8, pointerEvents: 'none',
}}>
<div style={{
width: 139, height: 5, borderRadius: 100,
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)',
}} />
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Keyboard — iOS 26 liquid glass
// ─────────────────────────────────────────────────────────────
function IOSKeyboard({ dark = false }) {
const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959';
const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333';
const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)';
// special-key icons
const icons = {
shift: <svg width="19" height="17" viewBox="0 0 19 17"><path d="M9.5 1L1 9.5h4.5V16h8V9.5H18L9.5 1z" fill={glyph}/></svg>,
del: <svg width="23" height="17" viewBox="0 0 23 17"><path d="M7 1h13a2 2 0 012 2v11a2 2 0 01-2 2H7l-6-7.5L7 1z" fill="none" stroke={glyph} strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 5l7 7M17 5l-7 7" stroke={glyph} strokeWidth="1.6" strokeLinecap="round"/></svg>,
ret: <svg width="20" height="14" viewBox="0 0 20 14"><path d="M18 1v6H4m0 0l4-4M4 7l4 4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
};
const key = (content, { w, flex, ret, fs = 25, k } = {}) => (
<div key={k} style={{
height: 42, borderRadius: 8.5,
flex: flex ? 1 : undefined, width: w, minWidth: 0,
background: ret ? '#08f' : keyBg,
boxShadow: '0 1px 0 rgba(0,0,0,0.075)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: '-apple-system, "SF Compact", system-ui',
fontSize: fs, fontWeight: 458, color: ret ? '#fff' : glyph,
}}>{content}</div>
);
const row = (keys, pad = 0) => (
<div style={{ display: 'flex', gap: 6.5, justifyContent: 'center', padding: `0 ${pad}px` }}>
{keys.map(l => key(l, { flex: true, k: l }))}
</div>
);
return (
<div style={{
position: 'relative', zIndex: 15, borderRadius: 27, overflow: 'hidden',
padding: '11px 0 2px',
display: 'flex', flexDirection: 'column', alignItems: 'center',
boxShadow: dark
? '0 -2px 20px rgba(0,0,0,0.09)'
: '0 -1px 6px rgba(0,0,0,0.018), 0 -3px 20px rgba(0,0,0,0.012)',
}}>
{/* liquid glass bg — same recipe as nav pills */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 27,
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
background: dark ? 'rgba(120,120,128,0.14)' : 'rgba(255,255,255,0.25)',
}} />
<div style={{
position: 'absolute', inset: 0, borderRadius: 27,
boxShadow: dark
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15)'
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
pointerEvents: 'none',
}} />
{/* autocorrect bar */}
<div style={{
display: 'flex', gap: 20, alignItems: 'center',
padding: '8px 22px 13px', width: '100%', boxSizing: 'border-box',
position: 'relative',
}}>
{['"The"', 'the', 'to'].map((w, i) => (
<React.Fragment key={i}>
{i > 0 && <div style={{ width: 1, height: 25, background: '#ccc', opacity: 0.3 }} />}
<div style={{
flex: 1, textAlign: 'center',
fontFamily: '-apple-system, system-ui', fontSize: 17,
color: sugg, letterSpacing: -0.43, lineHeight: '22px',
}}>{w}</div>
</React.Fragment>
))}
</div>
{/* key layout */}
<div style={{
display: 'flex', flexDirection: 'column', gap: 13,
padding: '0 6.5px', width: '100%', boxSizing: 'border-box',
position: 'relative',
}}>
{row(['q','w','e','r','t','y','u','i','o','p'])}
{row(['a','s','d','f','g','h','j','k','l'], 20)}
<div style={{ display: 'flex', gap: 14.25, alignItems: 'center' }}>
{key(icons.shift, { w: 45, k: 'shift' })}
<div style={{ display: 'flex', gap: 6.5, flex: 1 }}>
{['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))}
</div>
{key(icons.del, { w: 45, k: 'del' })}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{key('ABC', { w: 92.25, fs: 18, k: 'abc' })}
{key('', { flex: true, k: 'space' })}
{key(icons.ret, { w: 92.25, ret: true, k: 'ret' })}
</div>
</div>
{/* bottom spacer (emoji+mic area, icons omitted) */}
<div style={{ height: 56, width: '100%', position: 'relative' }} />
</div>
);
}
Object.assign(window, {
IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard,
});