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:
@@ -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 });
|
||||
Reference in New Issue
Block a user