docs(launcher): add Call to Play specification and design reference

Extend the launcher design specification and prototype reference with the
"Call to Play" multiplayer coordination feature and clean up top-bar chrome.

Key additions and changes:
- Spec & Roadmap: Document Call to Play mechanics in SPEC.md, including Play
  Now / Scheduled call flavors, 15-minute check-in windows, top-bar button
  with active count badge, quick-bar ticker stack above grid, overlay modal,
  and per-call group chat. Update design/README.md.
- Components & Logic: Add calltoplay.jsx and ctp-chat.jsx components for call
  creation, status progression, and chat. Extend data.jsx with a mock peer
  roster (PEERS) and helper functions for peer install count tracking.
- Top-bar Cleanup: Move game-folder configuration from top bar into Settings ->
  Library, freeing top-bar space for the Call to Play action button.
- Styling & Layout: Add CTP quick-bar, ticker, badge, card, and chat styles to
  styles.css, and integrate CTP components into launcher.jsx, components.jsx,
  and SoftLAN Launcher.html.

Test Plan:
- `git diff --cached --check` -- passed (no trailing whitespace or conflict markers)
- `cargo check --workspace` -- passed cleanly
- Manual review of staged diff across 9 design/launcher files -- confirmed clean staging
This commit is contained in:
2026-07-21 20:00:07 +02:00
parent 8d54e6e954
commit 8f151e38b4
9 changed files with 1831 additions and 10 deletions
@@ -23,6 +23,8 @@
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="data.jsx"></script>
<script type="text/babel" src="components.jsx"></script>
<script type="text/babel" src="ctp-chat.jsx"></script>
<script type="text/babel" src="calltoplay.jsx"></script>
<script type="text/babel" src="launcher.jsx"></script>
</head>
<body>
@@ -32,7 +34,7 @@
const { useState } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#3b82f6",
"accent": "#a855f7",
"density": "normal",
"aspect": "square",
"bg": "gradient",
@@ -47,9 +49,77 @@ function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const heroGame = GAMES.find(g => g.id === 'cs'); // installed + canHostServer → shows Play + Start Server + Uninstall
// Seed data for the "Call to Play" showcase — a live call with chat, a
// fresh call started by you, a SCHEDULED call whose 15-minute check-in
// window just opened (highlighted, you still have to ready up), and one
// scheduled for later tonight that's still collecting "I'm in" RSVPs.
const ctpSeeds = React.useMemo(() => {
const now = Date.now();
const me = (t.username || 'you').trim() || 'you';
return [
{
gameId: 'cs', creator: 'shadowfox', maxPlayers: 10, ageSec: 95, durationMin: 25,
participants: {
pixelqueen: { status: 'ready', joinedAt: now - 60000 },
kbmaster: { status: 'ready', joinedAt: now - 40000 },
nullptr: { status: 'pending', joinedAt: now - 20000, readyAt: now + 50000 },
},
messages: [
{ from: 'shadowfox', text: 'dust2, default rules — who is in?', agoSec: 90 },
{ from: 'kbmaster', text: 'in. someone seed the map pack for nullptr', agoSec: 60 },
{ from: 'nullptr', text: 'downloading, 2 min', agoSec: 35 },
],
},
{
gameId: 'l4d2', creator: me, maxPlayers: 4, ageSec: 15, durationMin: 6,
participants: {
afk_andy: { status: 'pending', joinedAt: now - 5000, readyAt: now + 35000 },
},
},
{
gameId: 'tf2', creator: 'wavedash', maxPlayers: 12, ageSec: 3600, scheduledInMin: 12,
participants: {
[me]: { status: 'in', joinedAt: now - 3000000 },
gg_val: { status: 'ready', joinedAt: now - 2000000 },
crit_happens: { status: 'in', joinedAt: now - 1500000 },
lowping_larry: { status: 'pending', joinedAt: now - 1000000, readyAt: now + 240000 },
},
messages: [
{ from: 'wavedash', text: 'payload maps tonight — check in when you are at your seat', agoSec: 300 },
{ from: 'gg_val', text: 'ready, lobby is open', agoSec: 120 },
],
},
{
gameId: 'amongus', creator: 'pixelqueen', maxPlayers: 10, ageSec: 5400, scheduledInMin: 128,
participants: {
ctrlaltdave: { status: 'in', joinedAt: now - 4000000 },
afk_andy: { status: 'in', joinedAt: now - 3000000 },
},
messages: [
{ from: 'pixelqueen', text: 'right after the pizza break — bring headphones', agoSec: 4800 },
{ from: 'ctrlaltdave', text: 'in. can we start 10 min later?', agoSec: 3600 },
],
},
];
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<React.Fragment>
<DesignCanvas>
<DCSection id="call-to-play" title="Call to Play — now, scheduled &amp; chat"
subtitle="Anyone can call a match — right now (give people N minutes to ready up) or scheduled for a clock time ('Among Us at 20:00'). Scheduled calls collect 'I'm in' RSVPs; 15 minutes before start the call enters its check-in window and lights up everywhere, nudging everyone who's in to answer Ready now or +N minutes. Each call carries a small group chat. The quick bars lead with the status — SCHEDULED / CALL TO PLAY / STARTING SOON / READY, each with its own LED color — sorted ready → starting soon → the rest (soonest first), with per-player ready-bubbles and the latest chat line inline. Click anything to interact live.">
<DCArtboard id="ctp-quickbars" label="Main view — quick bars with ready-bubbles + check-in highlight" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="all" initialSort="recent"
seedCallToPlay={ctpSeeds}/>
</DCArtboard>
<DCArtboard id="ctp-live" label="Overlay — live call, your call, check-in &amp; scheduled RSVP + chat" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="all" initialSort="recent"
seedCallToPlay={ctpSeeds} initialCtpOpen={true}/>
</DCArtboard>
</DCSection>
<DCSection id="chrome" title="Chrome variations"
subtitle="Two ways to organize the top bar — pick whichever density of controls you prefer. Click any card to open the detail overlay, or the kebab menu to open Settings.">
<DCArtboard id="single-row" label="A · Single-row + segmented filters" width={1340} height={840}>
@@ -0,0 +1,814 @@
// calltoplay.jsx — "Call to Play": rally the LAN around a game + a time.
//
// Two flavors of call, one feature:
// · Play NOW — pick a game, max players, and how long to give people.
// Others respond "Ready now" or "+N minutes". Resolves early if the
// roster fills, or when the clock runs out — the CALLER starts it.
// · SCHEDULED — pick a game and a clock time ("Among Us at 20:00").
// People RSVP with "I'm in". 15 minutes before start the call enters
// its CHECK-IN window: it gets highlighted everywhere and everyone
// who's in is nudged to answer with the same ready / +N-minute states.
//
// Every call also carries a small group chat (see ctp-chat.jsx).
//
// Loaded after data.jsx + components.jsx + ctp-chat.jsx, before launcher.jsx.
const { useState, useEffect, useMemo, useRef, useLayoutEffect } = React;
const GAMES_ = window.GAMES;
const PEERS_ = window.PEERS;
const installedPeersFor_ = window.installedPeersFor;
const parseMaxPlayers_ = window.parseMaxPlayers;
const Icon_ = window.Icon;
const GameCover_ = window.GameCover;
const CtpChat_ = window.CtpChat;
const avatarColor = window.ctpAvatarColor;
const fmtClock = window.ctpFmtClock;
// Check-in opens this long before a scheduled call's start time — and any
// call (scheduled or not) counts as "starting soon" inside this window.
const CHECKIN_LEAD_MS = 15 * 60000;
function phaseOf(n, now) {
if (!n.scheduledFor) return 'now';
return now < n.scheduledFor - CHECKIN_LEAD_MS ? 'scheduled' : 'checkin';
}
// Quick-bar status: READY > STARTING SOON > CALL TO PLAY / SCHEDULED.
function statusOf(n, now) {
if (n.state === 'started') return 'started';
if (n.state === 'done' || readyCountOf(n, now) >= n.maxPlayers) return 'ready';
if (n.deadline - now <= CHECKIN_LEAD_MS) return 'soon';
return n.scheduledFor ? 'scheduled' : 'call';
}
function fmtCountdown(ms) {
if (ms <= 0) return '0:00';
const total = Math.ceil(ms / 1000);
const m = Math.floor(total / 60), s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function fmtCountdownShort(ms) {
if (ms <= 0) return 'now';
const total = Math.ceil(ms / 1000);
if (total < 60) return `${total}s`;
return `${Math.ceil(total / 60)}m`;
}
function fmtUntil(ms) {
if (ms <= 60000) return 'in <1 min';
const min = Math.round(ms / 60000);
if (min < 60) return `in ${min} min`;
const h = Math.floor(min / 60), m = min % 60;
return m ? `in ${h}h ${m}m` : `in ${h}h`;
}
function isReady(p, now) {
if (!p) return false;
return p.status === 'ready' || (p.readyAt != null && now >= p.readyAt);
}
function readyCountOf(n, now) {
return Object.values(n.participants).filter(p => isReady(p, now)).length;
}
function inCountOf(n, now) {
return Object.values(n.participants).filter(p => !isReady(p, now) && p.status === 'in').length;
}
// ────────────────────────────────────────────────────────────────────
// Simulation — bots trickle into open calls (RSVP on scheduled ones,
// ready-up during check-in / now-calls) and occasionally post a chat
// line. Bots use second-scale buffers purely so the demo resolves
// visibly; real people would use minute-scale ones.
// ────────────────────────────────────────────────────────────────────
const CTP_BOT_LINES = [
'anyone need the game files? I can seed',
'gimme 5, grabbing food',
'who hosts?',
'voice is up — channel 2',
'ok, back at my desk',
'poke your neighbor, we need one more',
'default settings, no mods — right?',
'glhf',
];
function maybeBotChat(n, now) {
const msgs = n.messages || [];
const speakers = Object.keys(n.participants).filter(p => PEERS_.includes(p));
if (!speakers.length || msgs.length > 40 || Math.random() >= 0.03) return msgs;
const from = speakers[Math.floor(Math.random() * speakers.length)];
const text = CTP_BOT_LINES[Math.floor(Math.random() * CTP_BOT_LINES.length)];
return [...msgs, { id: `bm${now}${Math.random().toString(36).slice(2, 6)}`, from, text, at: now }];
}
function tickNomination(n, now) {
if (n.state !== 'open') return n;
const phase = phaseOf(n, now);
let participants = n.participants;
const joined = Object.keys(participants);
const available = PEERS_.filter(p => p !== n.creator && !joined.includes(p));
if (phase === 'scheduled') {
// RSVP trickle — far from start, people just say "I'm in".
if (available.length && joined.length < n.maxPlayers && Math.random() < 0.06) {
const bot = available[Math.floor(Math.random() * available.length)];
participants = { ...participants, [bot]: { status: 'in', joinedAt: now } };
}
return { ...n, participants, messages: maybeBotChat({ ...n, participants }, now) };
}
// Check-in window: RSVP'd bots convert to ready / "+N minutes".
const inBots = joined.filter(p => PEERS_.includes(p) && participants[p].status === 'in');
if (phase === 'checkin' && inBots.length && Math.random() < 0.22) {
const bot = inBots[Math.floor(Math.random() * inBots.length)];
participants = {
...participants,
[bot]: Math.random() < 0.6
? { ...participants[bot], status: 'ready' }
: { ...participants[bot], status: 'pending', readyAt: now + (10 + Math.random() * 70) * 1000 },
};
} else if (available.length && joined.length < n.maxPlayers && Math.random() < 0.10) {
// Walk-ins can still join late.
const bot = available[Math.floor(Math.random() * available.length)];
const readyNow = Math.random() < 0.55;
participants = {
...participants,
[bot]: readyNow
? { status: 'ready', joinedAt: now }
: { status: 'pending', joinedAt: now, readyAt: now + (10 + Math.random() * 70) * 1000 },
};
}
const messages = maybeBotChat({ ...n, participants }, now);
const readyCount = readyCountOf({ participants }, now);
const state = (readyCount >= n.maxPlayers || now >= n.deadline) ? 'done' : 'open';
return { ...n, participants, messages, state };
}
function makeSeedNomination({ gameId, creator, maxPlayers, ageSec = 0, durationMin = 10, scheduledInMin = null, participants = {}, messages = [] }) {
const now = Date.now() - ageSec * 1000;
const scheduledFor = scheduledInMin != null ? Date.now() + scheduledInMin * 60000 : null;
return {
id: `seed-${gameId}-${creator}`,
gameId, creator, maxPlayers,
createdAt: now,
scheduledFor,
deadline: scheduledFor != null ? scheduledFor : now + durationMin * 60000,
participants: { [creator]: { status: scheduledFor ? 'in' : 'ready', joinedAt: now }, ...participants },
messages: messages.map((m, i) => ({ id: `sm-${gameId}-${i}`, from: m.from, text: m.text, at: Date.now() - (m.agoSec || 0) * 1000 })),
state: 'open',
};
}
function useNominations({ username, seed = [] }) {
const [noms, setNoms] = useState(() => seed.map(makeSeedNomination));
useEffect(() => {
const id = setInterval(() => {
setNoms(prev => {
const now = Date.now();
return prev
.map(n => tickNomination(n, now))
.filter(n => !(n.state === 'started' && now - (n.startedAt || 0) > 2600));
});
}, 1000);
return () => clearInterval(id);
}, []);
const createNomination = (gameId, maxPlayers, durationMin, scheduledFor = null) => {
const now = Date.now();
const nom = {
id: `n${now}${Math.random().toString(36).slice(2, 7)}`,
gameId, creator: username, maxPlayers,
createdAt: now,
scheduledFor,
deadline: scheduledFor != null ? scheduledFor : now + durationMin * 60000,
participants: { [username]: { status: scheduledFor ? 'in' : 'ready', joinedAt: now } },
messages: [],
state: 'open',
};
setNoms(prev => [nom, ...prev]);
};
const respond = (id, choice) => {
setNoms(prev => prev.map(n => {
if (n.id !== id) return n;
const now = Date.now();
const participants = { ...n.participants };
participants[username] = choice === 'ready'
? { status: 'ready', joinedAt: (participants[username] || {}).joinedAt || now }
: { status: 'pending', joinedAt: (participants[username] || {}).joinedAt || now, readyAt: now + choice * 60000 };
return { ...n, participants };
}));
};
// RSVP for a scheduled call: "I'm in".
const rsvp = (id) => {
setNoms(prev => prev.map(n => n.id === id
? { ...n, participants: { ...n.participants, [username]: { status: 'in', joinedAt: Date.now() } } }
: n));
};
const sendMessage = (id, text) => {
const t = String(text || '').trim();
if (!t) return;
setNoms(prev => prev.map(n => n.id === id
? { ...n, messages: [...(n.messages || []), { id: `m${Date.now()}${Math.random().toString(36).slice(2, 6)}`, from: username, text: t, at: Date.now() }] }
: n));
};
const leave = (id) => {
setNoms(prev => prev.map(n => {
if (n.id !== id) return n;
const participants = { ...n.participants };
delete participants[username];
return { ...n, participants };
}));
};
const cancel = (id) => setNoms(prev => prev.filter(n => n.id !== id));
const startNow = (id) => setNoms(prev => prev.map(n => n.id === id ? { ...n, state: 'started', startedAt: Date.now() } : n));
const addTime = (id, minutes = 5) => setNoms(prev => prev.map(n => n.id === id ? { ...n, deadline: Date.now() + minutes * 60000, state: 'open' } : n));
return { nominations: noms, createNomination, respond, rsvp, sendMessage, leave, cancel, startNow, addTime };
}
// ────────────────────────────────────────────────────────────────────
// Top-bar entry point
// ────────────────────────────────────────────────────────────────────
function CallToPlayButton({ nominations, onClick }) {
const active = nominations.filter(n => n.state !== 'started');
return (
<button className="ctp-btn" onClick={onClick}>
<Icon_.flag/>
<span>Call to Play</span>
{active.length > 0 && <span className="ctp-badge">{active.length}</span>}
</button>
);
}
// ────────────────────────────────────────────────────────────────────
// Mini ready-bubbles — the per-player state, compressed for the
// quick bars: green ring + check = ready, "+Nm" tag = needs minutes,
// dimmed = RSVP'd but not checked in yet.
// ────────────────────────────────────────────────────────────────────
function MiniBubbles({ nomination: n, now, max = 6 }) {
const entries = Object.entries(n.participants);
const shown = entries.slice(0, max);
return (
<span className="ctp-ticker-bubbles">
{shown.map(([name, p]) => {
const ready = isReady(p, now);
const st = ready ? 'ready' : p.status === 'in' ? 'in' : 'pending';
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const title = ready ? `${name} — ready`
: st === 'in' ? `${name} — in, not checked in yet`
: `${name} — ready in ${fmtCountdownShort(p.readyAt - now)}`;
return (
<span key={name} className="ctp-mini" data-state={st} title={title} style={{ background: avatarColor(name) }}>
{initials}
{ready && <span className="ctp-mini-check"><Icon_.check/></span>}
{st === 'pending' && <i className="ctp-mini-tag">{fmtCountdownShort(p.readyAt - now)}</i>}
</span>
);
})}
{entries.length > max && <span className="ctp-mini ctp-mini-more">+{entries.length - max}</span>}
</span>
);
}
// ────────────────────────────────────────────────────────────────────
// Persistent quick bars — one row per active call, soonest first.
// Scheduled calls show their clock time; once check-in opens the row
// lights up to nudge everyone who said "I'm in".
// ────────────────────────────────────────────────────────────────────
const TICKER_LABEL = { scheduled: 'Scheduled', call: 'Call to Play', soon: 'Starting soon', ready: 'Ready' };
// Sort rank: ready first, starting-soon next, everything else after —
// ties broken by whichever resolves/starts soonest.
const TICKER_RANK = { ready: 0, soon: 1, call: 2, scheduled: 2 };
function CallToPlayTickerRow({ nomination: n, accent, onOpen }) {
const [, setTick] = useState(0);
useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, []);
const game = GAMES_.find(g => g.id === n.gameId);
if (!game) return null;
const now = Date.now();
const status = statusOf(n, now);
const ready = readyCountOf(n, now);
const total = Object.keys(n.participants).length;
const remaining = Math.max(0, n.deadline - now);
const msgs = n.messages || [];
const last = msgs[msgs.length - 1];
const count = status === 'scheduled' ? `${total} in` : `${ready}/${n.maxPlayers} ready`;
const time = status === 'ready' ? 'waiting to start'
: status === 'scheduled' ? `${fmtClock(n.scheduledFor)} · ${fmtUntil(n.scheduledFor - now)}`
: n.scheduledFor ? `starts ${fmtClock(n.scheduledFor)} · ${fmtCountdown(remaining)}`
: fmtCountdown(remaining);
return (
<button className="ctp-ticker" data-status={status} style={{ '--accent': accent }} onClick={() => onOpen(n.id)}>
<span className="ctp-ticker-dot" data-status={status}/>
<span className="ctp-ticker-label" data-status={status}>{TICKER_LABEL[status]}</span>
<span className="ctp-ticker-game">{game.title}</span>
<span className="ctp-ticker-by">by {n.creator}</span>
<span className="ctp-ticker-ready">{count}</span>
<span className="ctp-ticker-time">{time}</span>
{last ? (
<span className="ctp-ticker-chat" title={`${last.from}: ${last.text}`}>
<Icon_.chat/>
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b>
<span className="ctp-ticker-chat-text">{last.text}</span>
</span>
) : <span className="ctp-ticker-chat"/>}
<MiniBubbles nomination={n} now={now}/>
<span className="ctp-ticker-cta">{status === 'soon' ? 'Check in' : 'View'}<Icon_.chevron/></span>
</button>
);
}
function CallToPlayTicker({ nominations, accent, onOpen }) {
const now = Date.now();
const active = nominations.filter(n => n.state !== 'started')
.sort((a, b) => TICKER_RANK[statusOf(a, now)] - TICKER_RANK[statusOf(b, now)] || a.deadline - b.deadline);
if (!active.length) return null;
return (
<div className="ctp-ticker-stack">
{active.map(n => <CallToPlayTickerRow key={n.id} nomination={n} accent={accent} onOpen={onOpen}/>)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Avatar chip — ready / counting-down / RSVP'd ("in")
// ────────────────────────────────────────────────────────────────────
function AvatarChip({ name, participant, now }) {
const ready = isReady(participant, now);
const isIn = !ready && participant.status === 'in';
const remaining = participant.readyAt ? Math.max(0, participant.readyAt - now) : 0;
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const title = ready ? `${name} — ready`
: isIn ? `${name} — in, not checked in yet`
: `${name} — ready in ${fmtCountdown(remaining)}`;
return (
<div className={`ctp-avatar ${ready ? 'is-ready' : isIn ? 'is-in' : 'is-pending'}`} title={title}>
<span className="ctp-avatar-dot" style={{ background: avatarColor(name) }}>{initials}</span>
{ready
? <span className="ctp-avatar-check"><Icon_.check/></span>
: isIn
? <span className="ctp-avatar-in">in</span>
: <span className="ctp-avatar-pending">{fmtCountdownShort(remaining)}</span>}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Nomination card
// ────────────────────────────────────────────────────────────────────
function NominationCard({ nomination: n, username, accent, actions, focused }) {
const [, setTick] = useState(0);
useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, []);
const [confirmCancel, setConfirmCancel] = useState(false);
const game = GAMES_.find(g => g.id === n.gameId);
if (!game) return null;
const now = Date.now();
const entries = Object.entries(n.participants);
const readyCount = readyCountOf(n, now);
const inCount = inCountOf(n, now);
const isMe = username in n.participants;
const myStatus = n.participants[username];
const isCreator = n.creator === username;
const isDone = n.state === 'done';
const isStarted = n.state === 'started';
const phase = phaseOf(n, now);
const isSched = phase === 'scheduled' && !isDone && !isStarted;
const isCheckin = phase === 'checkin' && !isDone && !isStarted;
const windowStart = n.scheduledFor ? n.scheduledFor - CHECKIN_LEAD_MS : n.createdAt;
const totalMs = Math.max(1, n.deadline - windowStart);
const remaining = Math.max(0, n.deadline - now);
const pct = Math.max(0, Math.min(100, (remaining / totalMs) * 100));
const urgency = pct < 20 ? 'high' : pct < 50 ? 'mid' : 'low';
const peerCount = installedPeersFor_(game).length;
let actionsBody;
if (isStarted) {
actionsBody = <div className="ctp-note ctp-note-launch">Launching {game.title} for {n.maxPlayers} players</div>;
} else if (isSched) {
if (isCreator) {
actionsBody = <div className="ctp-note">Scheduled for {fmtClock(n.scheduledFor)} check-in opens 15 min before start.</div>;
} else if (isMe) {
actionsBody = (
<React.Fragment>
<div className="ctp-me-status">Youre in well nudge you to check in 15 min before start</div>
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Cant make it</button>
</React.Fragment>
);
} else {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.rsvp(n.id)}><Icon_.check/><span>Im in</span></button>
<div className="ctp-note">Check-in opens 15 min before start</div>
</React.Fragment>
);
}
} else if (isCreator) {
if (isDone) {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.startNow(n.id)}><Icon_.play/><span>Start now</span></button>
<button className="ghost-btn" onClick={() => actions.addTime(n.id, 5)}>Add 5 more minutes</button>
</React.Fragment>
);
} else if (myStatus && myStatus.status === 'in') {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
<div className="ctp-buffer-group">
{[5, 10, 15].map(min => (
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
))}
</div>
</React.Fragment>
);
} else {
actionsBody = <div className="ctp-note">Waiting for players to ready up</div>;
}
} else if (isDone) {
actionsBody = <div className="ctp-note">{readyCount >= n.maxPlayers ? 'Everyones ready.' : `Its time — waiting for ${n.creator} to start.`}</div>;
} else if (isMe) {
if (myStatus.status === 'in') {
actionsBody = (
<React.Fragment>
<div className="ctp-me-status">You said youre in check in:</div>
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
<div className="ctp-buffer-group">
{[5, 10, 15].map(min => (
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
))}
</div>
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Leave</button>
</React.Fragment>
);
} else {
const ready = isReady(myStatus, now);
actionsBody = (
<React.Fragment>
<div className="ctp-me-status">{ready ? 'Youre in — ready' : `You: ready in ${fmtCountdown(myStatus.readyAt - now)}`}</div>
{!ready && <button className="ghost-btn" onClick={() => actions.respond(n.id, 'ready')}>Im ready now</button>}
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Leave</button>
</React.Fragment>
);
}
} else {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
<div className="ctp-buffer-group">
{[5, 10, 15, 30].map(min => (
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
))}
</div>
</React.Fragment>
);
}
const timerEl = isStarted ? <div className="ctp-card-timer" data-urgency="off">Launching</div>
: isDone ? <div className="ctp-card-timer" data-urgency="off">Ready</div>
: isSched ? (
<div className="ctp-card-timer is-sched" data-urgency="off">
<span className="ctp-card-clock">{fmtClock(n.scheduledFor)}</span>
<span className="ctp-card-until">{fmtUntil(n.scheduledFor - now)}</span>
</div>
)
: isCheckin ? (
<div className="ctp-card-timer is-sched" data-urgency={urgency}>
<span className="ctp-card-clock">{fmtCountdown(remaining)}</span>
<span className="ctp-card-until">starts {fmtClock(n.scheduledFor)}</span>
</div>
)
: <div className="ctp-card-timer" data-urgency={urgency}>{fmtCountdown(remaining)}</div>;
const subEl = n.scheduledFor
? <React.Fragment>Scheduled by <strong>{n.creator}</strong> · starts at {fmtClock(n.scheduledFor)} · {peerCount}/{PEERS_.length} peers have it installed</React.Fragment>
: <React.Fragment>Called by <strong>{n.creator}</strong> · {peerCount}/{PEERS_.length} peers have it installed</React.Fragment>;
const rosterLabel = isSched
? `${entries.length} in · up to ${n.maxPlayers} players`
: isCheckin && inCount > 0
? `${readyCount}/${n.maxPlayers} ready · ${inCount} not checked in yet`
: `${readyCount}/${n.maxPlayers} ready`;
return (
<div className={`ctp-card ${isDone ? 'is-done' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}>
<div className="ctp-card-top">
<div className="ctp-card-cover"><GameCover_ game={game} aspect="square"/></div>
<div className="ctp-card-info">
<div className="ctp-card-title">{game.title}</div>
<div className="ctp-card-sub">{subEl}</div>
</div>
{timerEl}
</div>
{isCheckin && (
<div className="ctp-checkin-note">
<Icon_.clock/>
<span>{isMe && myStatus && myStatus.status === 'in'
? 'Starting soon — you said youre in. Check in below.'
: 'Starting soon — check-in is open.'}</span>
</div>
)}
{!isSched && (
<div className="ctp-progress">
<div className="ctp-progress-fill"
style={{ width: `${isStarted || isDone ? 100 : pct}%`, background: isDone || isStarted ? 'var(--ok)' : accent }}/>
</div>
)}
<div className="ctp-roster">
<div className="ctp-roster-count">{rosterLabel}</div>
<div className="ctp-avatars">
{entries.map(([name, p]) => <AvatarChip key={name} name={name} participant={p} now={now}/>)}
{Array.from({ length: Math.max(0, n.maxPlayers - entries.length) }).map((_, i) => (
<div key={`empty${i}`} className="ctp-avatar ctp-avatar-empty"/>
))}
</div>
</div>
<div className="ctp-actions">{actionsBody}</div>
<CtpChat_ nomination={n} username={username} disabled={isStarted}
onSend={(text) => actions.sendMessage(n.id, text)}/>
{isCreator && !isStarted && (
confirmCancel ? (
<div className="ctp-cancel-confirm">
<span>Cancel this call for everyone?</span>
<div className="ctp-cancel-confirm-btns">
<button className="ghost-btn ghost-danger" onClick={() => actions.cancel(n.id)}>Yes, cancel it</button>
<button className="ghost-btn" onClick={() => setConfirmCancel(false)}>No, keep it</button>
</div>
</div>
) : (
<button className="ctp-cancel-link" onClick={() => setConfirmCancel(true)}>
<Icon_.trash/><span>Cancel this call</span>
</button>
)
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Create-nomination form — "Now" or a scheduled clock time
// ────────────────────────────────────────────────────────────────────
function CreateNominationForm({ accent, onCancel, onCreate }) {
const [query, setQuery] = useState('');
const [gameId, setGameId] = useState(null);
const [maxPlayers, setMaxPlayers] = useState(8);
const [duration, setDuration] = useState(10);
const [when, setWhen] = useState('now');
const [dayOffset, setDayOffset] = useState(0);
const nextSlot = () => {
const d = new Date(Date.now() + 40 * 60000);
d.setMinutes(d.getMinutes() <= 30 ? 30 : 60, 0, 0);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const [timeStr, setTimeStr] = useState(nextSlot); // committed "HH:MM" (24h)
const [editing, setEditing] = useState(false);
const [timeDraft, setTimeDraft] = useState(timeStr);
const timeInputRef = useRef(null);
// Select-all when the type-a-time field appears, so the user can overtype
// immediately. Done in a layout effect (not onFocus+autoFocus) so it fires
// after the browser places the autofocus caret, and re-asserts if the
// per-second nominations tick re-renders the overlay.
useLayoutEffect(() => {
if (editing && timeInputRef.current) {
timeInputRef.current.focus();
timeInputRef.current.select();
}
}, [editing]);
const [hStr, mStr] = timeStr.split(':');
// Nudge the time by whole hours or minute-steps, wrapping within 023:59.
const bump = (field, delta) => {
let [h, m] = timeStr.split(':').map(Number);
if (field === 'h') h = (h + delta + 24) % 24;
else {
let total = h * 60 + m + delta;
total = ((total % 1440) + 1440) % 1440;
h = Math.floor(total / 60); m = total % 60;
}
setTimeStr(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
};
// Constrain free-text entry: digits + at most one colon, ≤2 digits per side
// (≤5 chars with a colon), ≤4 digits without one. Anything else is rejected
// (returns the previous draft) — so a colon can't be typed after 3 digits.
const sanitizeTime = (raw, prev) => {
const v = String(raw).replace(/[^\d:]/g, '');
if ((v.match(/:/g) || []).length > 1) return prev;
const ci = v.indexOf(':');
if (ci !== -1) {
if (v.slice(0, ci).length > 2 || v.slice(ci + 1).length > 2) return prev;
return v;
}
return v.length > 4 ? prev : v;
};
// Accepts "20:00", "2000", "20", "9:30" — normalizes or reverts.
const commitDraft = () => {
const mm = String(timeDraft).trim().match(/^(\d{1,2})(?:[:.\s]?(\d{2}))?$/);
if (mm) {
const h = Number(mm[1]), min = Number(mm[2] || 0);
if (h <= 23 && min <= 59) setTimeStr(`${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`);
}
setEditing(false);
};
const DE_DAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const dayChips = [0, 1, 2].map(off => {
const d = new Date();
d.setDate(d.getDate() + off);
const label = off === 0 ? 'Today' : DE_DAYS[d.getDay()];
return { off, label };
});
const matches = useMemo(() => {
if (!query.trim()) return [];
const q = query.toLowerCase();
return GAMES_.filter(g => g.title.toLowerCase().includes(q)).slice(0, 6);
}, [query]);
const selected = GAMES_.find(g => g.id === gameId);
const pickGame = (g) => {
setGameId(g.id);
setMaxPlayers(parseMaxPlayers_(g.players));
setQuery(g.title);
};
const scheduledTs = () => {
const [h, m] = timeStr.split(':').map(Number);
const d = new Date();
d.setDate(d.getDate() + dayOffset);
d.setHours(h || 0, m || 0, 0, 0);
if (d.getTime() < Date.now() + 60000) d.setDate(d.getDate() + 1);
return d.getTime();
};
return (
<div className="ctp-create">
<div className="ctp-create-row">
<label className="ctp-create-label">Game</label>
<div className="search ctp-create-search" style={{ '--accent': accent }}>
<Icon_.search/>
<input type="text" placeholder="Search the catalog…" value={query}
onChange={(e) => { setQuery(e.target.value); setGameId(null); }}/>
</div>
{!selected && query.trim() && (
<div className="ctp-create-matches">
{matches.map(g => (
<button key={g.id} onClick={() => pickGame(g)}>
<span>{g.title}</span>
<span className="ctp-create-match-meta">{g.players} players</span>
</button>
))}
{matches.length === 0 && <div className="ctp-create-nomatch">No games match "{query}"</div>}
</div>
)}
</div>
{selected && (
<React.Fragment>
<div className="ctp-create-row ctp-create-row-inline">
<label className="ctp-create-label">Max players</label>
<input type="number" className="ctp-create-num" min={2} max={64} value={maxPlayers}
onChange={(e) => setMaxPlayers(Math.max(2, Number(e.target.value) || 2))}/>
<span className="ctp-create-hint">Suggested for {selected.title}: {parseMaxPlayers_(selected.players)}</span>
</div>
<div className="ctp-create-row">
<label className="ctp-create-label">When</label>
<div className="ctp-duration-opts">
<button className={`ctp-duration-btn ${when === 'now' ? 'is-active' : ''}`}
style={when === 'now' ? { background: accent, borderColor: accent } : undefined}
onClick={() => setWhen('now')}>Now</button>
<button className={`ctp-duration-btn ${when === 'later' ? 'is-active' : ''}`}
style={when === 'later' ? { background: accent, borderColor: accent } : undefined}
onClick={() => setWhen('later')}>Schedule</button>
</div>
{when === 'later' && (
<React.Fragment>
<div className="ctp-timepick">
{editing ? (
<input ref={timeInputRef} type="text" inputMode="numeric" maxLength={5} placeholder="20:00"
className="ctp-time-editinput" value={timeDraft}
onChange={(e) => setTimeDraft(prev => sanitizeTime(e.target.value, prev))}
onBlur={commitDraft}
onKeyDown={(e) => { if (e.key === 'Enter') commitDraft(); if (e.key === 'Escape') setEditing(false); }}/>
) : (
<React.Fragment>
<div className="ctp-timepick-field">
<button type="button" className="ctp-time-step" aria-label="Hour up" onClick={() => bump('h', 1)}><Icon_.caretUp/></button>
<span className="ctp-time-cell">{hStr}</span>
<button type="button" className="ctp-time-step" aria-label="Hour down" onClick={() => bump('h', -1)}><Icon_.caretDown/></button>
</div>
<span className="ctp-time-colon">:</span>
<div className="ctp-timepick-field">
<button type="button" className="ctp-time-step" aria-label="Minute up" onClick={() => bump('m', 15)}><Icon_.caretUp/></button>
<span className="ctp-time-cell">{mStr}</span>
<button type="button" className="ctp-time-step" aria-label="Minute down" onClick={() => bump('m', -15)}><Icon_.caretDown/></button>
</div>
<button type="button" className="ctp-time-type"
onClick={() => { setTimeDraft(timeStr); setEditing(true); }}>Type a time</button>
</React.Fragment>
)}
</div>
<div className="ctp-time-row ctp-day-row">
<span className="ctp-day-label">Day</span>
{dayChips.map(c => (
<button key={c.off} className={`ctp-duration-btn ctp-day-btn ${dayOffset === c.off ? 'is-active' : ''}`}
style={dayOffset === c.off ? { background: accent, borderColor: accent } : undefined}
onClick={() => setDayOffset(c.off)}>{c.label}</button>
))}
</div>
<span className="ctp-create-hint">24-hour clock. People RSVP with Im in check-in to ready up opens 15 min before start.</span>
</React.Fragment>
)}
</div>
{when === 'now' && (
<div className="ctp-create-row">
<label className="ctp-create-label">Give people</label>
<div className="ctp-duration-opts">
{[5, 10, 15, 30, 60].map(min => (
<button key={min} className={`ctp-duration-btn ${duration === min ? 'is-active' : ''}`}
style={duration === min ? { background: accent, borderColor: accent } : undefined}
onClick={() => setDuration(min)}>{min}m</button>
))}
</div>
</div>
)}
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
<button className="act-btn act-play"
onClick={() => onCreate(selected.id, maxPlayers, duration, when === 'later' ? scheduledTs() : null)}>
{when === 'later'
? `Schedule it — ${selected.title} · ${dayOffset > 0 ? `${dayChips[dayOffset].label} ` : ''}${timeStr}`
: `Call it — ${selected.title}`}
</button>
</div>
</React.Fragment>
)}
{!selected && (
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Full overlay
// ────────────────────────────────────────────────────────────────────
function CallToPlayOverlay({ nominations, username, accent, onClose, actions, focusId }) {
const [showCreate, setShowCreate] = useState(false);
const sorted = [...nominations].sort((a, b) => (a.state === 'started' ? 1 : 0) - (b.state === 'started' ? 1 : 0) || a.deadline - b.deadline);
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal ctp-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label="Close"><Icon_.close/></button>
<div className="ctp-head">
<h2>Call to Play</h2>
<p className="ctp-head-sub">Rally the LAN around a game and a time right now, or scheduled for later with an Im in RSVP. The caller decides when it actually starts.</p>
{!showCreate && (
<button className="act-btn act-play ctp-head-new" onClick={() => setShowCreate(true)}>
<Icon_.flag/><span>Call a new match</span>
</button>
)}
</div>
<div className="ctp-body">
{showCreate && (
<CreateNominationForm accent={accent}
onCancel={() => setShowCreate(false)}
onCreate={(gid, max, dur, sched) => { actions.createNomination(gid, max, dur, sched); setShowCreate(false); }}/>
)}
{sorted.length === 0 && !showCreate && (
<div className="ctp-empty">No active calls right now be the one to start something.</div>
)}
{sorted.map(n => (
<NominationCard key={n.id} nomination={n} username={username} accent={accent} actions={actions}
focused={n.id === focusId}/>
))}
</div>
</div>
</div>
);
}
Object.assign(window, {
useNominations, makeSeedNomination,
CallToPlayButton, CallToPlayTicker, CallToPlayOverlay,
NominationCard, CreateNominationForm,
});
@@ -20,6 +20,12 @@ const Icon = {
check: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m3 8 3.5 3.5L13 5"/></svg>,
chevron:(p) => <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 6 4 4 4-4"/></svg>,
trash: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 4.5h10"/><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5"/><path d="M4.5 4.5 5 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-8.5"/></svg>,
flag: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3.5 1.5v13"/><path d="M3.5 2.25c3-1.3 4.5 1.3 7.5 0v6.7c-3 1.3-4.5-1.3-7.5 0"/></svg>,
clock: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/></svg>,
chat: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h9A1.5 1.5 0 0 1 14 3.5v6a1.5 1.5 0 0 1-1.5 1.5H6.5L3 14v-3h.5A1.5 1.5 0 0 1 2 9.5z"/></svg>,
send: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}><path d="M1.5 2 15 8 1.5 14l2.3-6z"/></svg>,
caretUp: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 10 4-4 4 4"/></svg>,
caretDown: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 6 4 4 4-4"/></svg>,
};
// ────────────────────────────────────────────────────────────────────
@@ -217,6 +223,10 @@ function GameCard({ game, accent, aspect, onOpen }) {
<span>{fmtSize(game.size)}</span>
<span className="card-dot">·</span>
<span>{game.tags[0]}</span>
<span className="card-dot">·</span>
<span className="card-peers" title={`${installedPeersFor(game).length} of ${PEERS.length} peers on the LAN have this installed`}>
<Icon.users/>{installedPeersFor(game).length}/{PEERS.length}
</span>
</div>
<ActionButton state={game.state} accent={accent} full game={game}/>
</div>
@@ -293,6 +303,7 @@ const SORTS = [
{ key: 'size', label: 'Size (largest)' },
{ key: 'recent', label: 'Recently Played' },
{ key: 'state', label: 'Status' },
{ key: 'peers', label: 'Peers with it (most)' },
];
function SortMenu({ value, onChange, accent }) {
@@ -0,0 +1,86 @@
// ctp-chat.jsx — shared Call-to-Play helpers (avatar colors, clock formatting)
// plus the per-call chat panel. Loaded after components.jsx, before calltoplay.jsx.
const { useState: useChatState, useEffect: useChatEffect, useRef: useChatRef } = React;
const CTP_AVATAR_COLORS = ['#60a5fa', '#34d399', '#c084fc', '#fbbf24', '#f472b6', '#38bdf8', '#a3e635', '#fb7185'];
const ctpHashName = (s) => [...String(s)].reduce((a, c) => a + c.charCodeAt(0), 0);
const ctpAvatarColor = (name) => CTP_AVATAR_COLORS[ctpHashName(name) % CTP_AVATAR_COLORS.length];
// 24h clock, LAN-party style: "20:00"
const ctpFmtClock = (ts) => {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
// ────────────────────────────────────────────────────────────────────
// Per-call chat — collapsed to a one-line toggle with an unread badge
// and a preview of the last message; expands to a compact IRC-style
// message list + input. All state is local to the card.
// ────────────────────────────────────────────────────────────────────
function CtpChat({ nomination: n, username, onSend, disabled }) {
const IconX = window.Icon;
const msgs = n.messages || [];
const [open, setOpen] = useChatState(false);
const [seen, setSeen] = useChatState(msgs.length);
const [draft, setDraft] = useChatState('');
const listRef = useChatRef(null);
useChatEffect(() => { if (open) setSeen(msgs.length); }, [open, msgs.length]);
useChatEffect(() => {
if (open && listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
}, [open, msgs.length]);
const unread = Math.max(0, msgs.length - seen);
const last = msgs[msgs.length - 1];
const submit = () => {
const t = draft.trim();
if (!t) return;
onSend(t);
setDraft('');
};
return (
<div className={`ctp-chat ${open ? 'is-open' : ''}`}>
<button className="ctp-chat-toggle" onClick={() => setOpen(o => !o)}>
<IconX.chat/>
<span>Chat</span>
{msgs.length > 0 && <span className="ctp-chat-count">{msgs.length}</span>}
{!open && unread > 0 && <span className="ctp-chat-unread">{unread}</span>}
{!open && last && (
<span className="ctp-chat-preview">
<b style={{ color: ctpAvatarColor(last.from) }}>{last.from}:</b> {last.text}
</span>
)}
<span className="ctp-chat-chevron"><IconX.chevron/></span>
</button>
{open && (
<React.Fragment>
<div className="ctp-chat-list" ref={listRef}>
{msgs.length === 0 && <div className="ctp-chat-empty">No messages yet say hi.</div>}
{msgs.map(m => (
<div key={m.id} className={`ctp-chat-msg ${m.from === username ? 'is-me' : ''}`}>
<b style={{ color: ctpAvatarColor(m.from) }}>{m.from}</b>
<span className="ctp-chat-time">{ctpFmtClock(m.at)}</span>
<span className="ctp-chat-text"> {m.text}</span>
</div>
))}
</div>
{!disabled && (
<div className="ctp-chat-form">
<input type="text" className="ctp-chat-input" placeholder="Message the group…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}/>
<button className="ctp-chat-send" onClick={submit} aria-label="Send"><IconX.send/></button>
</div>
)}
</React.Fragment>
)}
</div>
);
}
Object.assign(window, { CtpChat, ctpAvatarColor, ctpFmtClock });
+30
View File
@@ -203,7 +203,37 @@ const STORAGE = {
total: 512,
};
// ────────────────────────────────────────────────────────────────────
// LAN roster (mock) — used by the peer-install indicator and by the
// Call to Play feature to simulate other people in the room.
// ────────────────────────────────────────────────────────────────────
const PEERS = [
'shadowfox', 'pixelqueen', 'kbmaster', 'nullptr', 'wavedash',
'crit_happens', 'afk_andy', 'lowping_larry', 'gg_val', 'ctrlaltdave',
];
const hashStr = (s) => [...String(s)].reduce((a, c) => a + c.charCodeAt(0), 0);
// Deterministic-but-varied subset of PEERS who "have this game installed" —
// stable per game id so the same game always shows the same roster/count.
function installedPeersFor(game) {
const h = hashStr(game.id);
const count = 1 + (h % PEERS.length); // 1..PEERS.length
const scored = PEERS.map((p, i) => ({ p, s: (h * 7 + i * 13 + hashStr(p)) % 97 }));
scored.sort((a, b) => a.s - b.s);
return scored.slice(0, count).map(x => x.p);
}
// "216" / "1-8" → 16 / 8. Falls back to 8 if unparsable.
function parseMaxPlayers(playersStr) {
const m = String(playersStr).match(/(\d+)\s*$/);
return m ? parseInt(m[1], 10) : 8;
}
window.GAMES = GAMES;
window.PEERS = PEERS;
window.installedPeersFor = installedPeersFor;
window.parseMaxPlayers = parseMaxPlayers;
window.STATE_META = STATE_META;
window.ACTION_FOR_STATE = ACTION_FOR_STATE;
window.countByFilter = countByFilter;
@@ -15,6 +15,8 @@ function applyFilterAndSort(games, filter, sort, query) {
} else if (sort === 'recent') {
const order = { installed: 0, local: 1, none: 2 };
g = [...g].sort((a, b) => order[a.state] - order[b.state] || b.version.localeCompare(a.version));
} else if (sort === 'peers') {
g = [...g].sort((a, b) => installedPeersFor(b).length - installedPeersFor(a).length || a.title.localeCompare(b.title));
}
return g;
}
@@ -25,6 +27,8 @@ function Launcher({
initialFilter = 'all', initialSort = 'recent', initialQuery = '',
initialOpenGame = null,
initialSettingsOpen = false,
seedCallToPlay = [],
initialCtpOpen = false,
}) {
const { density, aspect, accent, bg } = tweaks;
const [filter, setFilter] = useState(initialFilter);
@@ -32,6 +36,11 @@ function Launcher({
const [query, setQuery] = useState(initialQuery);
const [openGame, setOpenGame] = useState(initialOpenGame);
const [settingsOpen, setSettingsOpen] = useState(initialSettingsOpen);
const [ctpOpen, setCtpOpen] = useState(initialCtpOpen);
const [ctpFocusId, setCtpFocusId] = useState(null);
const nomUsername = tweaks.username && tweaks.username.trim() ? tweaks.username.trim() : 'you';
const ctp = useNominations({ username: nomUsername, seed: seedCallToPlay });
const counts = useMemo(() => countByFilter(GAMES), []);
const list = useMemo(() => applyFilterAndSort(GAMES, filter, sort, query), [filter, sort, query]);
@@ -66,6 +75,7 @@ function Launcher({
<SortMenu value={sort} onChange={setSort} accent={accent}/>
</div>
<div className="topbar-right-trail">
<CallToPlayButton nominations={ctp.nominations} onClick={() => setCtpOpen(true)}/>
<KebabMenu items={menuItems}/>
</div>
</div>
@@ -79,6 +89,7 @@ function Launcher({
</div>
<div className="topbar-row1-right">
<StorageMeter accent={accent}/>
<CallToPlayButton nominations={ctp.nominations} onClick={() => setCtpOpen(true)}/>
<KebabMenu items={menuItems}/>
</div>
</div>
@@ -93,6 +104,8 @@ function Launcher({
)}
<main className="grid-wrap">
<CallToPlayTicker nominations={ctp.nominations} accent={accent}
onOpen={(id) => { setCtpFocusId(id); setCtpOpen(true); }}/>
{variant === 'single' && (
<div className="results-bar">
<div className="results-count">
@@ -113,6 +126,12 @@ function Launcher({
{settingsOpen && setTweak && (
<SettingsDialog settings={tweaks} onChange={setTweak} onClose={() => setSettingsOpen(false)}/>
)}
{ctpOpen && (
<CallToPlayOverlay nominations={ctp.nominations} username={nomUsername} accent={accent}
focusId={ctpFocusId}
onClose={() => { setCtpOpen(false); setCtpFocusId(null); }}
actions={{ createNomination: ctp.createNomination, respond: ctp.respond, rsvp: ctp.rsvp, sendMessage: ctp.sendMessage, leave: ctp.leave, cancel: ctp.cancel, startNow: ctp.startNow, addTime: ctp.addTime }}/>
)}
</div>
);
}
+628 -1
View File
@@ -636,8 +636,14 @@
font-size: 11.5px;
color: var(--t-3);
font-variant-numeric: tabular-nums;
min-width: 0;
}
.card-meta .card-dot { opacity: 0.5; }
.card-meta .card-dot { opacity: 0.5; flex-shrink: 0; }
.card-peers {
display: inline-flex; align-items: center; gap: 3px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.card-peers svg { flex-shrink: 0; opacity: 0.75; }
.density-compact .card-body { padding: 9px 10px 10px; gap: 6px; }
.density-compact .card-title { font-size: 12.5px; }
@@ -1311,3 +1317,624 @@
.folder-field.is-unset .folder-field-btn:hover {
background: var(--accent, #3b82f6);
}
/* ═══════════════════════════════════════════════════════════════════
Call to Play — rally a game + a time
═══════════════════════════════════════════════════════════════════ */
/* ─── Top-bar entry button ─── */
.ctp-btn {
position: relative;
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px;
background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 8px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background .15s, border-color .15s;
}
.ctp-btn svg { color: var(--accent); flex-shrink: 0; }
.ctp-btn:hover {
background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2));
}
.ctp-badge {
display: inline-grid; place-items: center;
min-width: 18px; height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 10.5px; font-weight: 800;
font-variant-numeric: tabular-nums;
}
/* ─── Ticker strip ─── */
.ctp-ticker-stack {
display: flex; flex-direction: column; gap: 8px;
margin-bottom: 14px;
}
.ctp-ticker-stack .ctp-ticker { margin-bottom: 0; }
.ctp-ticker {
display: grid;
grid-template-columns:
10px /* LED */
98px /* status */
220px /* game */
130px /* by */
88px /* count */
170px /* time */
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
max-content /* bubbles */
82px; /* cta */
align-items: center;
column-gap: 12px;
width: 100%;
padding: 10px 16px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
border-radius: 10px;
color: var(--t-1);
font: inherit; font-size: 12.5px;
cursor: pointer;
text-align: left;
transition: background .15s, border-color .15s;
}
.ctp-ticker:hover { background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); }
.ctp-ticker-dot {
width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0;
background: var(--accent);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
animation: ctp-tickerpulse 1.6s ease-out infinite;
}
@keyframes ctp-tickerpulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-ticker-label {
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
font-size: 10.5px;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.ctp-ticker-game { font-weight: 700; color: var(--t-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-by { color: var(--t-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-sep { display: none; }
.ctp-ticker-ready, .ctp-ticker-time { color: var(--t-2); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-more {
color: var(--t-3);
font-size: 11.5px;
flex-shrink: 0;
}
.ctp-ticker-cta {
display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px;
font-weight: 700;
color: var(--accent);
}
.ctp-ticker-cta svg { transform: rotate(-90deg); }
/* ─── Overlay modal ─── */
.ctp-modal { width: min(720px, 100%); }
.ctp-head-row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
margin-right: 36px;
}
.ctp-head-new { height: 40px; padding: 0 18px; font-size: 13px; margin-top: 14px; width: 100%; }
.ctp-head { padding: 26px 28px 14px; border-bottom: 1px solid var(--bd-1); }
.ctp-head h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--t-1); }
.ctp-head-sub { margin: 6px 0 0; font-size: 12.5px; color: var(--t-3); max-width: 52ch; }
.ctp-body {
padding: 18px 24px 24px;
display: flex; flex-direction: column; gap: 14px;
max-height: 66vh;
overflow: auto;
}
.ctp-empty {
padding: 28px 8px;
text-align: center;
color: var(--t-3);
font-size: 13px;
}
/* ─── Nomination card ─── */
.ctp-card {
display: flex; flex-direction: column; gap: 12px;
padding: 14px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--bd-1);
border-radius: 12px;
}
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
.ctp-card.is-started { opacity: 0.6; }
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
@keyframes ctp-cardflash {
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-card-top { display: flex; align-items: flex-start; gap: 12px; position: relative; }
.ctp-card-cover {
position: relative;
width: 52px; height: 52px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.ctp-card-info { flex: 1; min-width: 0; }
.ctp-card-title { font-size: 14.5px; font-weight: 700; color: var(--t-1); }
.ctp-card-sub { margin-top: 3px; font-size: 11.5px; color: var(--t-3); }
.ctp-card-sub strong { color: var(--t-2); font-weight: 700; }
.ctp-card-timer {
font-variant-numeric: tabular-nums;
font-size: 18px;
font-weight: 700;
color: var(--t-1);
flex-shrink: 0;
padding-top: 2px;
}
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
.ctp-card-timer[data-urgency="high"] { color: var(--danger); }
.ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; }
.ctp-cancel-link {
display: inline-flex; align-items: center; gap: 6px;
align-self: flex-start;
margin-top: -2px;
padding: 6px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .15s;
}
.ctp-cancel-link:hover { color: #fca5a5; }
.ctp-cancel-confirm {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
gap: 10px;
padding: 10px 12px;
background: rgba(239,68,68,0.08);
border: 1px solid rgba(239,68,68,0.35);
border-radius: 8px;
font-size: 12.5px;
color: #fca5a5;
font-weight: 600;
}
.ctp-cancel-confirm-btns { display: inline-flex; gap: 8px; flex-shrink: 0; }
.ctp-cancel-confirm-btns .ghost-btn { height: 32px; padding: 0 12px; font-size: 12px; }
.ctp-progress {
height: 5px;
border-radius: 3px;
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.ctp-progress-fill { height: 100%; transition: width 1s linear; }
.ctp-roster { display: flex; flex-direction: column; gap: 8px; }
.ctp-roster-count { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--t-3); }
.ctp-avatars { display: flex; flex-wrap: wrap; gap: 6px; }
.ctp-avatar {
position: relative;
display: inline-flex;
}
.ctp-avatar-dot {
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 999px;
color: white;
font-size: 10.5px; font-weight: 800;
letter-spacing: 0.01em;
}
.ctp-avatar.is-pending .ctp-avatar-dot { opacity: 0.55; }
.ctp-avatar-check {
position: absolute; bottom: -2px; right: -2px;
width: 14px; height: 14px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 2px solid var(--bg-2);
}
.ctp-avatar-pending {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 9px; font-weight: 700;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.ctp-avatar-empty {
width: 30px; height: 30px;
border-radius: 999px;
border: 1.5px dashed var(--bd-2);
}
.ctp-actions {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
padding-top: 2px;
}
.ctp-actions .act-btn { height: 36px; padding: 0 16px; font-size: 12.5px; }
.ctp-actions .ghost-btn { height: 36px; padding: 0 14px; font-size: 12.5px; }
.ctp-note { font-size: 12.5px; color: var(--t-3); }
.ctp-note-launch { color: var(--ok); font-weight: 600; }
.ctp-me-status { font-size: 12.5px; font-weight: 600; color: var(--t-2); }
.ctp-buffer-group { display: inline-flex; gap: 6px; }
.ctp-buffer-btn {
height: 36px; padding: 0 11px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--bd-2);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px; font-weight: 600;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.ctp-buffer-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); }
/* ─── Create-nomination form ─── */
.ctp-create-cta {
display: flex; align-items: center; justify-content: center; gap: 8px;
height: 48px;
background: transparent;
border: 1.5px dashed var(--bd-3);
border-radius: 12px;
color: var(--t-2);
font: inherit; font-size: 13px; font-weight: 700;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.ctp-create-cta:hover { border-color: var(--accent); color: var(--t-1); background: rgba(255,255,255,0.02); }
.ctp-create {
display: flex; flex-direction: column; gap: 14px;
padding: 16px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--bd-2);
border-radius: 12px;
}
.ctp-create-row { display: flex; flex-direction: column; gap: 6px; position: relative; }
.ctp-create-row-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
.ctp-create-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
}
.ctp-create-search { width: 100%; }
.ctp-create-matches {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
z-index: 20;
max-height: 220px;
overflow: auto;
padding: 4px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 10px;
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
}
.ctp-create-matches button {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%;
padding: 9px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 600;
text-align: left;
cursor: pointer;
}
.ctp-create-matches button:hover { background: rgba(255,255,255,0.06); }
.ctp-create-match-meta { color: var(--t-3); font-size: 11px; font-weight: 500; }
.ctp-create-nomatch { padding: 10px; font-size: 12px; color: var(--t-3); }
.ctp-create-num {
width: 70px; height: 34px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 13px; font-weight: 600;
text-align: center;
}
.ctp-create-hint { font-size: 11.5px; color: var(--t-3); }
.ctp-duration-opts { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.ctp-duration-btn {
height: 32px; padding: 0 13px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
.ctp-duration-btn:hover { color: var(--t-1); }
.ctp-duration-btn.is-active { color: white; }
.ctp-create-foot {
display: flex; justify-content: flex-end; gap: 10px;
padding-top: 4px;
}
.ctp-create-foot .act-btn,
.ctp-create-foot .ghost-btn { height: 40px; padding: 0 18px; }
.ctp-time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
/* ─── Time stepper ─── */
.ctp-timepick {
display: flex; align-items: center; gap: 6px;
margin-top: 6px;
}
.ctp-timepick-field {
display: flex; flex-direction: column; align-items: center; gap: 3px;
}
.ctp-time-step {
width: 54px; height: 24px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
cursor: pointer;
transition: background .12s, color .12s, border-color .12s;
}
.ctp-time-step:hover { background: var(--bg-4); color: var(--t-1); border-color: var(--bd-3); }
.ctp-time-step:active { background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); }
.ctp-time-cell {
width: 54px; height: 44px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 9px;
font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
letter-spacing: 0.02em;
}
.ctp-time-editinput {
width: 132px; height: 44px;
padding: 0;
background: var(--bg-3);
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2));
border-radius: 9px;
font: inherit; font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
text-align: center;
letter-spacing: 0.06em;
}
.ctp-time-editinput:focus { outline: none; }
.ctp-time-editinput::placeholder { color: var(--t-4); }
.ctp-time-colon { font-size: 26px; font-weight: 700; color: var(--t-2); padding-bottom: 2px; }
.ctp-time-type {
align-self: center;
margin-left: 8px;
height: 30px; padding: 0 12px;
background: transparent;
border: 1px dashed var(--bd-3);
border-radius: 7px;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .12s, border-color .12s;
}
.ctp-time-type:hover { color: var(--t-1); border-color: var(--accent); }
.ctp-day-row { gap: 5px; }
.ctp-day-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
margin-right: 4px;
}
.ctp-day-btn { height: 26px; padding: 0 10px; font-size: 11px; border-radius: 6px; }
/* ─── Quick-bar status variants — LED + label + row tint per status:
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
STARTING SOON (amber, glowing) · READY (green, steady) ─── */
.ctp-ticker[data-status="scheduled"] {
background: var(--bg-2);
border-color: var(--bd-2);
}
.ctp-ticker[data-status="scheduled"]:hover { background: var(--bg-3); }
.ctp-ticker[data-status="soon"] {
background: color-mix(in srgb, var(--warn) 10%, var(--bg-2));
border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2));
animation: ctp-soon-glow 2.4s ease-in-out infinite;
}
.ctp-ticker[data-status="soon"]:hover { background: color-mix(in srgb, var(--warn) 16%, var(--bg-2)); }
@keyframes ctp-soon-glow {
0%, 100% { box-shadow: 0 0 0 0 transparent; }
50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); }
}
.ctp-ticker[data-status="ready"] {
background: color-mix(in srgb, var(--ok) 11%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-2));
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent);
}
.ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); }
.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; }
.ctp-ticker-dot[data-status="soon"] { background: var(--warn); animation: ctp-tickerpulse-warn 1.6s ease-out infinite; }
.ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); }
@keyframes ctp-tickerpulse-warn {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
70% { box-shadow: 0 0 0 6px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); }
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
/* ─── Quick-bar inline chat preview ─── */
.ctp-ticker-chat {
min-width: 0;
display: inline-flex; align-items: center; gap: 5px;
color: var(--t-3);
font-size: 12px;
}
.ctp-ticker-chat svg { flex-shrink: 0; opacity: 0.7; }
.ctp-ticker-chat b { flex-shrink: 0; font-weight: 700; }
.ctp-ticker-chat-text {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--t-2);
}
.ctp-card.is-checkin { border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2)); }
.ctp-checkin-note {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px;
background: color-mix(in srgb, var(--accent) 13%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
border-radius: 8px;
font-size: 12.5px; font-weight: 600;
color: var(--t-1);
}
.ctp-checkin-note svg { color: var(--accent); flex-shrink: 0; }
.ctp-card-timer.is-sched {
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
}
.ctp-card-clock { font-size: 18px; line-height: 1; }
.ctp-card-until { font-size: 10.5px; font-weight: 600; color: var(--t-3); letter-spacing: 0.01em; }
.ctp-avatar.is-in .ctp-avatar-dot { opacity: 0.8; }
.ctp-avatar-in {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 8.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
}
/* ─── Ticker mini ready-bubbles ─── */
.ctp-ticker-bubbles {
display: inline-flex; align-items: center;
justify-self: end;
}
.ctp-mini {
position: relative;
width: 22px; height: 22px;
display: grid; place-items: center;
border-radius: 999px;
margin-left: -6px;
border: 2px solid var(--bg-2);
color: white;
font-size: 8px; font-weight: 800; letter-spacing: 0.02em;
}
.ctp-mini:first-child { margin-left: 0; }
.ctp-mini[data-state="ready"] { box-shadow: 0 0 0 1.5px var(--ok); }
.ctp-mini[data-state="pending"] { opacity: 0.75; }
.ctp-mini[data-state="in"] { opacity: 0.85; }
.ctp-mini-check {
position: absolute; bottom: -3px; right: -4px;
width: 11px; height: 11px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 1.5px solid var(--bg-2);
}
.ctp-mini-check svg { width: 7px; height: 7px; }
.ctp-mini-tag {
position: absolute; bottom: -6px; right: -7px;
font-style: normal;
font-size: 8px; font-weight: 700;
line-height: 11px;
padding: 0 3px;
border-radius: 999px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
color: var(--t-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ctp-mini-more { background: var(--bg-4); color: var(--t-2); font-size: 8.5px; }
/* ─── Per-call chat ─── */
.ctp-chat {
display: flex; flex-direction: column;
border-top: 1px solid var(--bd-1);
margin-top: 2px;
padding-top: 8px;
}
.ctp-chat-toggle {
display: flex; align-items: center; gap: 8px;
width: 100%;
padding: 4px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 700;
cursor: pointer;
transition: color .15s;
}
.ctp-chat-toggle:hover { color: var(--t-1); }
.ctp-chat-toggle svg { flex-shrink: 0; }
.ctp-chat-count { color: var(--t-4); font-weight: 600; }
.ctp-chat-unread {
min-width: 16px; height: 16px;
display: grid; place-items: center;
padding: 0 4px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 9.5px; font-weight: 800;
flex-shrink: 0;
}
.ctp-chat-preview {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 500;
color: var(--t-3);
text-align: left;
}
.ctp-chat-preview b { font-weight: 700; }
.ctp-chat-chevron { margin-left: auto; flex-shrink: 0; transition: transform .15s; }
.ctp-chat.is-open .ctp-chat-chevron { transform: rotate(180deg); }
.ctp-chat-list {
display: flex; flex-direction: column; gap: 6px;
max-height: 168px;
overflow-y: auto;
margin: 8px 0;
padding: 10px 12px;
background: rgba(0,0,0,0.22);
border: 1px solid var(--bd-1);
border-radius: 8px;
}
.ctp-chat-msg { font-size: 12px; line-height: 1.45; color: var(--t-2); overflow-wrap: anywhere; }
.ctp-chat-msg b { font-weight: 700; }
.ctp-chat-text { color: var(--t-1); }
.ctp-chat-time { margin-left: 6px; font-size: 10px; color: var(--t-4); font-variant-numeric: tabular-nums; }
.ctp-chat-empty { font-size: 11.5px; color: var(--t-4); text-align: center; padding: 6px 0; }
.ctp-chat-form { display: flex; gap: 6px; }
.ctp-chat-input {
flex: 1; height: 32px;
padding: 0 10px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px;
}
.ctp-chat-input::placeholder { color: var(--t-4); }
.ctp-chat-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); }
.ctp-chat-send {
width: 32px; height: 32px;
display: grid; place-items: center;
background: color-mix(in srgb, var(--accent) 24%, var(--bg-3));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 7px;
color: var(--t-1);
cursor: pointer;
flex-shrink: 0;
transition: background .15s;
}
.ctp-chat-send:hover { background: color-mix(in srgb, var(--accent) 40%, var(--bg-3)); }