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:
Zebratic
2026-07-11 02:24:37 +00:00
commit 02a90e839c
20 changed files with 1835 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
// Minimal service worker for MV3. Mostly exists to be valid.
// (Future: track badge state for unread chat messages.)
chrome.runtime.onInstalled.addListener(() => {
// no-op
});
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type === 'bbp:ping') {
sendResponse({ ok: true });
return true;
}
return false;
});
+253
View File
@@ -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;
}
+89
View File
@@ -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;
}
+52
View File
@@ -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);
+282
View File
@@ -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;
}
+51
View File
@@ -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))';
}
}
+95
View File
@@ -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]');
}
+55
View File
@@ -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 };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 603 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

+34
View File
@@ -0,0 +1,34 @@
{
"manifest_version": 3,
"name": "Beat Pocket",
"short_name": "Beat Pocket",
"version": "0.1.0",
"description": "Mobile-first UI for beat-battle.net. Chat drawer, single-column layout, thumb-sized controls. Uses your existing session — no login required.",
"minimum_chrome_version": "109",
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"action": {
"default_title": "Beat Pocket",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png"
}
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["*://beat-battle.net/*"],
"run_at": "document_idle",
"js": ["content/index.js"],
"css": ["content/inject.css"]
}
],
"permissions": ["storage"],
"host_permissions": ["*://beat-battle.net/*"]
}