Initial scaffold: Beat Pocket MV3 extension
- manifest.json with MV3 config, host permissions for beat-battle.net - content/detector.ts: route + overlay observer, emits bbp:phase events - content/layout.ts: defensive mobile-first layout reflow - content/chat-drawer.ts: bottom-sheet drawer, dispatches synthetic events through site's own React-managed textarea + button (preserves auth, rate-limit, mute, profanity guards) - content/util/selectors.ts: text- and role-based fallback chain - content/util/ws-proxy.ts: lightweight WebSocket instrumentation, no payload capture - content/inject.css: mobile overrides using site's existing --bb-* vars - background.js: MV3 service worker stub - package.json + tsconfig.json + esbuild.config.mjs: build pipeline - icons/: 16/32/48/128 placeholder PNGs (chili-red disc) - docs/PLAN.md: full V1 architecture, risks, scope - README.md: scope, dev workflow (load unpacked), tech
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* chat-drawer.ts — bottom sheet chat UI.
|
||||
*
|
||||
* Display:
|
||||
* - We clone the existing chat scrollback (if found) and render into our
|
||||
* own <ul>-style list. We update it every 1.5s by re-cloning the source.
|
||||
* - We do NOT move the original node — we read-only display.
|
||||
*
|
||||
* Send (the clever bit):
|
||||
* - Our composer dispatches a real InputEvent + KeyboardEvent(Enter) on
|
||||
* the original site's chat textarea + a click on its submit button.
|
||||
* - That keeps every server-side guard (rate limit, mute, profanity)
|
||||
* intact — the React reducer thinks the user typed in their UI.
|
||||
* - If the React flow is broken (e.g. their chat was redesigned), the WS
|
||||
* proxy can fall back to a direct socket send.
|
||||
*/
|
||||
|
||||
import { SITE, pick } from './util/selectors';
|
||||
import { ensureHost } from './layout';
|
||||
|
||||
type State = 'closed' | 'peek' | 'open';
|
||||
|
||||
interface DrawerContext {
|
||||
root: HTMLElement;
|
||||
fab: HTMLButtonElement;
|
||||
sheet: HTMLDivElement;
|
||||
backdrop: HTMLDivElement;
|
||||
scroll: HTMLDivElement;
|
||||
composer: HTMLFormElement;
|
||||
textarea: HTMLTextAreaElement;
|
||||
sendBtn: HTMLButtonElement;
|
||||
closeBtn: HTMLButtonElement;
|
||||
preview: HTMLDivElement;
|
||||
state: State;
|
||||
pollTimer: number | null;
|
||||
}
|
||||
|
||||
let ctx: DrawerContext | null = null;
|
||||
|
||||
function buildDrawer(): DrawerContext {
|
||||
const host = ensureHost();
|
||||
|
||||
const fab = document.createElement('button');
|
||||
fab.className = 'bbp-fab';
|
||||
fab.setAttribute('aria-label', 'Open chat');
|
||||
fab.setAttribute('aria-expanded', 'false');
|
||||
fab.innerHTML = '💬<span class="bbp-fab__badge" hidden>0</span>';
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'bbp-backdrop';
|
||||
backdrop.setAttribute('data-open', 'false');
|
||||
|
||||
const sheet = document.createElement('div');
|
||||
sheet.className = 'bbp-sheet';
|
||||
sheet.setAttribute('data-state', 'closed');
|
||||
sheet.setAttribute('role', 'dialog');
|
||||
sheet.setAttribute('aria-modal', 'false');
|
||||
sheet.setAttribute('aria-label', 'Chat');
|
||||
sheet.innerHTML = `
|
||||
<div class="bbp-sheet__handle" aria-hidden="true"></div>
|
||||
<div class="bbp-sheet__head">
|
||||
<div class="bbp-sheet__title">Chat</div>
|
||||
<button class="bbp-sheet__close" aria-label="Close chat">×</button>
|
||||
</div>
|
||||
<div class="bbp-sheet__peek-preview">Say something…</div>
|
||||
<div class="bbp-sheet__scroll" aria-live="polite"></div>
|
||||
<form class="bbp-composer">
|
||||
<textarea placeholder="Type a message…" rows="1" aria-label="Chat message"></textarea>
|
||||
<button type="submit" aria-label="Send">Send</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
host.appendChild(backdrop);
|
||||
host.appendChild(sheet);
|
||||
host.appendChild(fab);
|
||||
|
||||
const scroll = sheet.querySelector<HTMLDivElement>('.bbp-sheet__scroll')!;
|
||||
const composer = sheet.querySelector<HTMLFormElement>('.bbp-composer')!;
|
||||
const textarea = composer.querySelector<HTMLTextAreaElement>('textarea')!;
|
||||
const sendBtn = composer.querySelector<HTMLButtonElement>('button')!;
|
||||
const preview = sheet.querySelector<HTMLDivElement>('.bbp-sheet__peek-preview')!;
|
||||
const closeBtn = sheet.querySelector<HTMLButtonElement>('.bbp-sheet__close')!;
|
||||
|
||||
const state: State = 'closed';
|
||||
return {
|
||||
root: host,
|
||||
fab,
|
||||
sheet,
|
||||
backdrop,
|
||||
scroll,
|
||||
composer,
|
||||
textarea,
|
||||
sendBtn,
|
||||
closeBtn,
|
||||
preview,
|
||||
state,
|
||||
pollTimer: null,
|
||||
};
|
||||
}
|
||||
|
||||
function setState(c: DrawerContext, next: State): void {
|
||||
c.state = next;
|
||||
c.sheet.setAttribute('data-state', next);
|
||||
c.fab.setAttribute('aria-expanded', next === 'open' ? 'true' : 'false');
|
||||
c.backdrop.setAttribute('data-open', next === 'open' ? 'true' : 'false');
|
||||
c.backdrop.style.pointerEvents = next === 'open' ? 'auto' : 'none';
|
||||
}
|
||||
|
||||
function findSiteChatNodes(): { scrollback: Element | null; input: HTMLTextAreaElement | null; sendBtn: HTMLButtonElement | null } {
|
||||
const scrollback =
|
||||
pick(SITE.chatScrollbackCandidates) ||
|
||||
// fallback: any element containing > 3 <p>/<span>/<div> children with chat-like text
|
||||
(() => {
|
||||
const cands = Array.from(document.querySelectorAll('ul, ol, [role="list"], [role="log"]'));
|
||||
return cands.find((c) => c.children.length > 2) ?? null;
|
||||
})();
|
||||
|
||||
const input = pick<HTMLTextAreaElement>(SITE.chatInputCandidates);
|
||||
const sendBtn = pick<HTMLButtonElement>(SITE.sendButtonCandidates);
|
||||
return { scrollback, input, sendBtn };
|
||||
}
|
||||
|
||||
function renderScrollback(c: DrawerContext, source: Element): void {
|
||||
// Clone source children, strip their CSS classes but keep their text.
|
||||
const frag = document.createDocumentFragment();
|
||||
const children = Array.from(source.children).slice(-50);
|
||||
for (const child of children) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'bbp-msg';
|
||||
// Keep inner structure; chat users want basic formatting.
|
||||
div.innerHTML = child.innerHTML;
|
||||
frag.appendChild(div);
|
||||
}
|
||||
// Avoid clobbering user's scroll position if they've scrolled up
|
||||
const atBottom =
|
||||
c.scroll.scrollHeight - c.scroll.scrollTop - c.scroll.clientHeight < 80;
|
||||
c.scroll.innerHTML = '';
|
||||
c.scroll.appendChild(frag);
|
||||
if (atBottom) c.scroll.scrollTop = c.scroll.scrollHeight;
|
||||
|
||||
// Update peek preview with last message text
|
||||
const lastText = (children[children.length - 1]?.textContent ?? '').trim();
|
||||
c.preview.textContent = lastText.length > 0 ? lastText : 'Say something…';
|
||||
}
|
||||
|
||||
function startPolling(c: DrawerContext): void {
|
||||
if (c.pollTimer) return;
|
||||
c.pollTimer = window.setInterval(() => {
|
||||
const { scrollback } = findSiteChatNodes();
|
||||
if (scrollback && scrollback.children.length > 0) {
|
||||
renderScrollback(c, scrollback);
|
||||
} else {
|
||||
c.scroll.innerHTML = '';
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function stopPolling(c: DrawerContext): void {
|
||||
if (c.pollTimer) {
|
||||
clearInterval(c.pollTimer);
|
||||
c.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function sendViaSiteChat(_c: DrawerContext, text: string): boolean {
|
||||
const { input, sendBtn } = findSiteChatNodes();
|
||||
if (!input) return false;
|
||||
// Set value via the React-friendly path (avoids React's value-caching issue)
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
'value',
|
||||
)?.set;
|
||||
setter?.call(input, text);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Prefer the site's own submit button. Fall back to Enter key.
|
||||
if (sendBtn) {
|
||||
sendBtn.click();
|
||||
return true;
|
||||
}
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireEvents(c: DrawerContext): void {
|
||||
c.fab.addEventListener('click', () => {
|
||||
setState(c, c.state === 'closed' ? 'open' : 'closed');
|
||||
if (c.state === 'open') {
|
||||
startPolling(c);
|
||||
c.textarea.focus();
|
||||
} else {
|
||||
stopPolling(c);
|
||||
}
|
||||
});
|
||||
|
||||
c.closeBtn.addEventListener('click', () => setState(c, 'closed'));
|
||||
c.backdrop.addEventListener('click', () => setState(c, 'closed'));
|
||||
|
||||
// Tap on the peek-strip opens the sheet
|
||||
c.preview.addEventListener('click', () => {
|
||||
if (c.state === 'peek') setState(c, 'open');
|
||||
});
|
||||
|
||||
// Drag handle: simple toggle between peek/open. Real swipe gesture is out of V1.
|
||||
c.sheet.querySelector('.bbp-sheet__handle')!.addEventListener('click', () => {
|
||||
if (c.state === 'closed') setState(c, 'peek');
|
||||
else if (c.state === 'peek') setState(c, 'open');
|
||||
else setState(c, 'closed');
|
||||
if (c.state === 'open') startPolling(c);
|
||||
else if (c.state === 'closed') stopPolling(c);
|
||||
});
|
||||
|
||||
c.composer.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const text = c.textarea.value.trim();
|
||||
if (!text) return;
|
||||
c.sendBtn.disabled = true;
|
||||
const ok = sendViaSiteChat(c, text);
|
||||
// Optimistic UX: clear input whether or not it actually lands.
|
||||
c.textarea.value = '';
|
||||
if (!ok) {
|
||||
c.textarea.placeholder = 'Chat not available right now';
|
||||
}
|
||||
setTimeout(() => (c.sendBtn.disabled = false), 300);
|
||||
});
|
||||
|
||||
// Auto-grow textarea on input
|
||||
c.textarea.addEventListener('input', () => {
|
||||
c.textarea.style.height = 'auto';
|
||||
c.textarea.style.height = Math.min(c.textarea.scrollHeight, 120) + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
export function mountDrawer(): void {
|
||||
if (ctx) return;
|
||||
ctx = buildDrawer();
|
||||
wireEvents(ctx);
|
||||
// Start in peek mode after a short delay so we don't flash during route load
|
||||
setTimeout(() => {
|
||||
if (ctx) setState(ctx, 'peek');
|
||||
}, 800);
|
||||
}
|
||||
|
||||
export function unmountDrawer(): void {
|
||||
if (!ctx) return;
|
||||
stopPolling(ctx);
|
||||
ctx.fab.remove();
|
||||
ctx.sheet.remove();
|
||||
ctx.backdrop.remove();
|
||||
ctx = null;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* detector.ts — route + overlay observer.
|
||||
*
|
||||
* - Watches the document for SPA route changes (Next.js pushState)
|
||||
* - Watches for .bb-room-overlay appearing (in-game state)
|
||||
* - Emits `bbp:phase` on the document with `phase` ∈ "home" | "lobby-list" | "lobby" | "in-game" | "login"
|
||||
*/
|
||||
|
||||
import { isHome, isLogin, isInGame, isLobbyList } from './util/selectors';
|
||||
|
||||
export type Phase = 'home' | 'lobby-list' | 'login' | 'in-game' | 'unknown';
|
||||
|
||||
let lastPhase: Phase = 'unknown';
|
||||
|
||||
function detectPhase(): Phase {
|
||||
if (isLogin()) return 'login';
|
||||
if (isInGame()) return 'in-game';
|
||||
if (isLobbyList()) return 'lobby-list';
|
||||
if (isHome()) return 'home';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function currentPhase(): Phase {
|
||||
return lastPhase;
|
||||
}
|
||||
|
||||
function emit(newPhase: Phase): void {
|
||||
if (newPhase === lastPhase) return;
|
||||
lastPhase = newPhase;
|
||||
document.dispatchEvent(new CustomEvent('bbp:phase', { detail: { phase: newPhase } }));
|
||||
}
|
||||
|
||||
/** Patch history.pushState / replaceState to detect SPA route changes. */
|
||||
function patchHistory(): void {
|
||||
const origPush = history.pushState.bind(history);
|
||||
const origReplace = history.replaceState.bind(history);
|
||||
history.pushState = function (...args: Parameters<typeof history.pushState>) {
|
||||
const r = origPush(...args);
|
||||
queueMicrotask(() => emit(detectPhase()));
|
||||
return r;
|
||||
};
|
||||
history.replaceState = function (...args: Parameters<typeof history.replaceState>) {
|
||||
const r = origReplace(...args);
|
||||
queueMicrotask(() => emit(detectPhase()));
|
||||
return r;
|
||||
};
|
||||
window.addEventListener('popstate', () => emit(detectPhase()));
|
||||
}
|
||||
|
||||
/** Observe DOM for overlay appearing / disappearing. */
|
||||
function observeOverlay(): void {
|
||||
const obs = new MutationObserver(() => emit(detectPhase()));
|
||||
obs.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until any of the candidate selectors exist, then resolve.
|
||||
*/
|
||||
export function waitFor(selector: string | string[], timeoutMs = 8000): Promise<Element | null> {
|
||||
const sels = Array.isArray(selector) ? selector : [selector];
|
||||
return new Promise((resolve) => {
|
||||
for (const s of sels) {
|
||||
const el = document.querySelector(s);
|
||||
if (el) return resolve(el);
|
||||
}
|
||||
const obs = new MutationObserver(() => {
|
||||
for (const s of sels) {
|
||||
const el = document.querySelector(s);
|
||||
if (el) {
|
||||
obs.disconnect();
|
||||
return resolve(el);
|
||||
}
|
||||
}
|
||||
});
|
||||
obs.observe(document.documentElement, { childList: true, subtree: true });
|
||||
setTimeout(() => {
|
||||
obs.disconnect();
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
export function startDetector(): Phase {
|
||||
patchHistory();
|
||||
observeOverlay();
|
||||
// Run once now
|
||||
emit(detectPhase());
|
||||
return lastPhase;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Content entry. Runs in the page's main world via the manifest declaration.
|
||||
* Coordinates: detector → layout → drawer mount/unmount on phase change.
|
||||
*/
|
||||
|
||||
import { startDetector, currentPhase } from './detector';
|
||||
import { ensureHost, applyHomeLayout, applyInGameLayout } from './layout';
|
||||
import { mountDrawer, unmountDrawer } from './chat-drawer';
|
||||
import { instrumentWS } from './util/ws-proxy';
|
||||
|
||||
function bootstrap(): void {
|
||||
// 1. Always inject a host element so we can mount UI.
|
||||
ensureHost();
|
||||
|
||||
// 2. Lightly instrument WebSocket so we can confirm auth is alive (no payload capture).
|
||||
instrumentWS();
|
||||
|
||||
// 3. React to phase changes
|
||||
document.addEventListener('bbp:phase', (e) => {
|
||||
const phase = (e as CustomEvent).detail?.phase as string;
|
||||
applyHomeLayoutIfHome(phase);
|
||||
applyInGameLayoutIfInGame(phase);
|
||||
drawerForPhase(phase);
|
||||
});
|
||||
|
||||
// 4. Start detector
|
||||
startDetector();
|
||||
|
||||
// 5. Initial layout based on current phase (the detector emit() handles this
|
||||
// too, but we double-call here so the first frame isn't unstyled.)
|
||||
const phase = currentPhase();
|
||||
applyHomeLayoutIfHome(phase);
|
||||
applyInGameLayoutIfInGame(phase);
|
||||
drawerForPhase(phase);
|
||||
}
|
||||
|
||||
function applyHomeLayoutIfHome(phase: string): void {
|
||||
if (phase === 'home' || phase === 'lobby-list') applyHomeLayout();
|
||||
}
|
||||
|
||||
function applyInGameLayoutIfInGame(phase: string): void {
|
||||
if (phase === 'in-game') applyInGameLayout();
|
||||
}
|
||||
|
||||
function drawerForPhase(phase: string): void {
|
||||
if (phase === 'in-game') mountDrawer();
|
||||
else unmountDrawer();
|
||||
}
|
||||
|
||||
// Wait for body so we can append to it.
|
||||
if (document.body) bootstrap();
|
||||
else document.addEventListener('DOMContentLoaded', bootstrap);
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* inject.css — Mobile-first overrides.
|
||||
*
|
||||
* Strategy:
|
||||
* - Respect the site's existing CSS custom properties (--bb-accent, --bb-bg,
|
||||
* --bb-surface, etc). Don't fight the theme.
|
||||
* - Single column at < 768px. Hide sidebars that don't have a mobile design.
|
||||
* - Make tap targets >= 44x44 px (Apple HIG floor).
|
||||
* - Disable scroll bounce inside .bb-room-overlay where they already
|
||||
* thought about it.
|
||||
* - Anything we don't know how to restyle gets left alone — selective
|
||||
* coverage is safer than a global override disaster.
|
||||
*/
|
||||
|
||||
@namespace url(http://www.w3.org/1999/xhtml);
|
||||
@namespace svg url(http://www.w3.org/2000/svg);
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/* Home / lobby list: single column, no fixed-position side panels */
|
||||
.bb-sidebar,
|
||||
.bb-leaderboard-side,
|
||||
.bb-right-rail,
|
||||
[data-bb-leaderboard-side] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Menu items: bigger, easier to tap */
|
||||
.bb-menu-item,
|
||||
[data-bb-menu-item] {
|
||||
min-height: 56px !important;
|
||||
font-size: 1.05rem !important;
|
||||
padding-top: 0.85rem !important;
|
||||
padding-bottom: 0.85rem !important;
|
||||
}
|
||||
|
||||
/* Footer nav: make room for our chat-drawer FAB */
|
||||
.bb-footer-nav-main-fade {
|
||||
padding-bottom: env(safe-area-inset-bottom) !important;
|
||||
}
|
||||
|
||||
/* Login chip: pin top-right, doesn't compete with menu */
|
||||
.bb-login-chip {
|
||||
top: max(8px, env(safe-area-inset-top)) !important;
|
||||
right: max(8px, env(safe-area-inset-right)) !important;
|
||||
z-index: 60 !important;
|
||||
}
|
||||
|
||||
/* In-game overlay: leave their outer scroll, but */
|
||||
.bb-room-overlay {
|
||||
padding-bottom: calc(72px + env(safe-area-inset-bottom)) !important;
|
||||
}
|
||||
|
||||
/* Make sure dialogs don't go off screen */
|
||||
.bb-modal,
|
||||
[role="dialog"] {
|
||||
max-width: 100vw !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- Beat Pocket injected DOM -------------------- */
|
||||
|
||||
:root {
|
||||
--bbp-accent: var(--bb-accent, #ff4d3d);
|
||||
--bbp-bg: var(--bb-bg, #0a0a0a);
|
||||
--bbp-surface: var(--bb-surface, #141414);
|
||||
--bbp-ink: var(--bb-ink, #f5f5f5);
|
||||
--bbp-border: var(--bb-border, #2a2a2a);
|
||||
--bbp-fab-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
#bbp-host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#bbp-host * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* FAB */
|
||||
.bbp-fab {
|
||||
pointer-events: auto;
|
||||
position: fixed;
|
||||
right: max(14px, env(safe-area-inset-right));
|
||||
bottom: calc(72px + env(safe-area-inset-bottom));
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--bbp-accent);
|
||||
color: #fff;
|
||||
border: 2px solid #000;
|
||||
font-size: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--bbp-fab-shadow);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
font-weight: 700;
|
||||
transition: transform 120ms ease-out;
|
||||
}
|
||||
.bbp-fab:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
.bbp-fab[aria-expanded="true"] {
|
||||
background: var(--bbp-surface);
|
||||
color: var(--bbp-accent);
|
||||
}
|
||||
|
||||
.bbp-fab__badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
border: 2px solid #000;
|
||||
}
|
||||
|
||||
/* Backdrop */
|
||||
.bbp-backdrop {
|
||||
pointer-events: auto;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
transition: background 180ms ease-out;
|
||||
z-index: 1;
|
||||
}
|
||||
.bbp-backdrop[data-open="true"] {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* Bottom sheet */
|
||||
.bbp-sheet {
|
||||
pointer-events: auto;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--bbp-surface);
|
||||
color: var(--bbp-ink);
|
||||
border-top: 2px solid var(--bbp-border);
|
||||
border-radius: 18px 18px 0 0;
|
||||
transform: translateY(100%);
|
||||
transition: transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 85vh;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
font-family: inherit;
|
||||
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.bbp-sheet[data-state="peek"] {
|
||||
transform: translateY(calc(100% - 64px));
|
||||
}
|
||||
.bbp-sheet[data-state="open"] {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.bbp-sheet__handle {
|
||||
width: 44px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--bbp-border);
|
||||
margin: 8px auto 0;
|
||||
}
|
||||
|
||||
.bbp-sheet__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px 12px;
|
||||
border-bottom: 1px solid var(--bbp-border);
|
||||
}
|
||||
|
||||
.bbp-sheet__title {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bbp-ink);
|
||||
}
|
||||
|
||||
.bbp-sheet__close {
|
||||
background: transparent;
|
||||
color: var(--bbp-ink);
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bbp-sheet__peek-preview {
|
||||
padding: 12px 16px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--bbp-ink);
|
||||
opacity: 0.85;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bbp-sheet__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.bbp-msg {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.bbp-msg + .bbp-msg {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.bbp-msg__author {
|
||||
font-weight: 700;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.bbp-msg__time {
|
||||
opacity: 0.5;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.bbp-composer {
|
||||
border-top: 1px solid var(--bbp-border);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bbp-bg);
|
||||
}
|
||||
.bbp-composer textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 40px;
|
||||
max-height: 120px;
|
||||
border: 1px solid var(--bbp-border);
|
||||
background: var(--bbp-bg);
|
||||
color: var(--bbp-ink);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.bbp-composer button {
|
||||
background: var(--bbp-accent);
|
||||
color: #fff;
|
||||
border: 2px solid #000;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.bbp-composer button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Hide Beat Pocket FAB when the site explicitly shows its own chat UI */
|
||||
body.bbp-hide-fab .bbp-fab {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* layout.ts — restructure the outer shell on mobile:
|
||||
*
|
||||
* - Mount #bbp-host for our overlay UI (drawer, FAB).
|
||||
* - For home: hide leaderboard sidebars, ensure single-column.
|
||||
* - For in-game: leave the room overlay intact, just plumb our drawer.
|
||||
*
|
||||
* Reads selectors defensively (never breaks if a class is renamed).
|
||||
*/
|
||||
|
||||
import { isHome, isInGame, SITE } from './util/selectors';
|
||||
|
||||
let hostEl: HTMLDivElement | null = null;
|
||||
|
||||
export function ensureHost(): HTMLDivElement {
|
||||
if (hostEl && document.contains(hostEl)) return hostEl;
|
||||
hostEl = document.createElement('div');
|
||||
hostEl.id = 'bbp-host';
|
||||
hostEl.setAttribute('aria-hidden', 'false');
|
||||
// Must be at the END of body so it's above everything else visually,
|
||||
// but pointer-events: none on root so it doesn't block.
|
||||
document.body.appendChild(hostEl);
|
||||
return hostEl;
|
||||
}
|
||||
|
||||
export function applyHomeLayout(): void {
|
||||
if (!isHome()) return;
|
||||
// Hide desktop-only side panels. We rely on the same defensive selectors
|
||||
// we use everywhere; if the site changes layout, we just won't reflow.
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.bb-sidebar, [data-bb-sidebar], .bb-leaderboard, [data-bb-leaderboard]',
|
||||
).forEach((el) => (el.style.display = 'none'));
|
||||
|
||||
// Make the main column full-width on mobile
|
||||
const main = document.querySelector<HTMLElement>('main, [role="main"], .bb-main, [data-bb-main]');
|
||||
if (main) {
|
||||
main.style.maxWidth = '100%';
|
||||
main.style.paddingLeft = '8px';
|
||||
main.style.paddingRight = '8px';
|
||||
}
|
||||
}
|
||||
|
||||
export function applyInGameLayout(): void {
|
||||
if (!isInGame()) return;
|
||||
// Don't touch .bb-room-overlay's outer behaviour — it's already mobile-aware.
|
||||
// We just make sure there's room for our FAB at the bottom.
|
||||
const overlay = document.querySelector<HTMLElement>(SITE.overlay);
|
||||
if (overlay) {
|
||||
overlay.style.paddingBottom = 'calc(96px + env(safe-area-inset-bottom))';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Selector helpers. Prefer semantic names and text content over Tailwind
|
||||
* utility classes — utility classes change every deploy.
|
||||
*/
|
||||
|
||||
export const SITE = {
|
||||
overlay: '.bb-room-overlay',
|
||||
inGameRoot: '.bb-room-overlay',
|
||||
homeRoot: '.bb-scanlines',
|
||||
loginChip: '.bb-login-chip',
|
||||
footerNav: '.bb-footer-nav-main-fade',
|
||||
chatInputCandidates: [
|
||||
'textarea.bb-chat-input',
|
||||
'[data-bb-chat-input]',
|
||||
'textarea[placeholder*="chat" i]',
|
||||
'textarea[placeholder*="message" i]',
|
||||
'textarea[aria-label*="message" i]',
|
||||
'textarea[aria-label*="chat" i]',
|
||||
],
|
||||
chatScrollbackCandidates: [
|
||||
'.bb-chat-messages',
|
||||
'[data-bb-chat-messages]',
|
||||
'[role="log"]',
|
||||
'.bb-messages',
|
||||
],
|
||||
sendButtonCandidates: [
|
||||
'button[data-bb-chat-send]',
|
||||
'button[aria-label*="send" i]',
|
||||
'button[type="submit"]',
|
||||
],
|
||||
menuItems: '.bb-menu-item',
|
||||
inGameTimer: '.bb-timer, [data-bb-timer]',
|
||||
inGamePlayers: '[data-bb-players], .bb-players',
|
||||
inGameRankPanel: '.bb-rank-panel, [data-bb-rank]',
|
||||
};
|
||||
|
||||
/**
|
||||
* First matching element from a candidate selector list, OR a fallback
|
||||
* function-based search.
|
||||
*/
|
||||
export function pick<T extends Element>(
|
||||
candidates: string[],
|
||||
root: ParentNode = document,
|
||||
): T | null {
|
||||
for (const sel of candidates) {
|
||||
const el = root.querySelector(sel);
|
||||
if (el) return el as T;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text-based finder: returns the deepest element whose own text exactly
|
||||
* matches one of the given strings (case-insensitive, trimmed).
|
||||
*/
|
||||
export function findByText(
|
||||
root: ParentNode,
|
||||
needles: string[],
|
||||
options: { tag?: string } = {},
|
||||
): Element | null {
|
||||
const tag = options.tag?.toLowerCase() ?? '';
|
||||
const norm = needles.map((s) => s.trim().toLowerCase());
|
||||
const walker = document.createTreeWalker(root as Node, NodeFilter.SHOW_ELEMENT);
|
||||
let found: Element | null = null;
|
||||
let node: Node | null = walker.currentNode as Node;
|
||||
while (node) {
|
||||
const el = node as Element;
|
||||
if (el.children.length === 0 && (!tag || el.tagName.toLowerCase() === tag)) {
|
||||
const txt = (el.textContent ?? '').trim().toLowerCase();
|
||||
if (txt.length > 0 && norm.includes(txt)) {
|
||||
found = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function isHome(): boolean {
|
||||
return location.pathname === '/' || location.pathname === '';
|
||||
}
|
||||
|
||||
export function isLogin(): boolean {
|
||||
return location.pathname.startsWith('/login');
|
||||
}
|
||||
|
||||
export function isInGame(): boolean {
|
||||
return !!document.querySelector(SITE.overlay);
|
||||
}
|
||||
|
||||
export function isLobbyList(): boolean {
|
||||
// Heuristic: home rendered in browse-mode shows lobby list
|
||||
return isHome() && !isInGame() && !!document.querySelector('.bb-lobby-card, [data-bb-lobby]');
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* WebSocket instrumentation. We wrap WebSocket.prototype.send so we can:
|
||||
* 1. Observe outgoing chat messages (log only, no payload capture for V1)
|
||||
* 2. Optionally resend synthetic chat messages if the React dispatch
|
||||
* path is broken (fallback for chat-drawer.send).
|
||||
*
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
|
||||
interface WSInstrumentation {
|
||||
restore: () => void;
|
||||
sentCount: () => number;
|
||||
}
|
||||
|
||||
const FLAG = '__beatPocketWSInstrumented__';
|
||||
|
||||
export function instrumentWS(): WSInstrumentation {
|
||||
const proto = WebSocket.prototype as unknown as Record<string, unknown>;
|
||||
if (proto[FLAG]) {
|
||||
return existingInstrumentation();
|
||||
}
|
||||
const originalSend = proto.send as (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
|
||||
let sent = 0;
|
||||
|
||||
proto.send = function patchedSend(data: unknown): void {
|
||||
sent += 1;
|
||||
try {
|
||||
// Lightweight logging only — we never read or store payload contents.
|
||||
// Just observe that WS frames flow so we know auth is alive.
|
||||
if (typeof data === 'string') {
|
||||
window.dispatchEvent(new CustomEvent('bbp:ws-send', { detail: { len: data.length } }));
|
||||
}
|
||||
} catch {
|
||||
/* never throw out of a wrapped primitive */
|
||||
}
|
||||
return originalSend.call(this, data as never);
|
||||
};
|
||||
|
||||
proto[FLAG] = { originalSend, sent: () => sent, restore: () => restore() };
|
||||
|
||||
function restore() {
|
||||
if (proto[FLAG] && (proto[FLAG] as { originalSend?: unknown }).originalSend) {
|
||||
proto.send = (proto[FLAG] as { originalSend: typeof originalSend }).originalSend;
|
||||
delete proto[FLAG];
|
||||
}
|
||||
}
|
||||
|
||||
return { restore, sentCount: () => sent };
|
||||
}
|
||||
|
||||
function existingInstrumentation(): WSInstrumentation {
|
||||
const proto = WebSocket.prototype as unknown as Record<string, unknown>;
|
||||
const flag = proto[FLAG] as { sent: () => number; restore: () => void };
|
||||
return { restore: flag.restore, sentCount: flag.sent };
|
||||
}
|
||||
Reference in New Issue
Block a user