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
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
*.zip
*.pem
*.key
.DS_Store
.env
.env.local
+68
View File
@@ -0,0 +1,68 @@
# Beat Pocket
A browser extension that gives [beat-battle.net](https://beat-battle.net) a
mobile-first UI without modifying the site itself. We never modify the
site's code or call its private API — we only restyle the DOM it gives us
and splice in our own components.
## Status
**V1 in progress.** See [`docs/PLAN.md`](./docs/PLAN.md) for full design.
- [x] Plan
- [ ] Scaffolding (this commit)
- [ ] Build via Codex
- [ ] Smoke-test on Chromium "load unpacked"
- [ ] Package for Chrome Web Store
## Scope (V1)
| Screen | Treatment |
|---|---|
| Home (`/`) | Single-column, big thumb buttons, sticky login chip |
| Lobby list | Card-per-room, full-width |
| In-game | Player list → top, score/timer → safe top, action bar → sticky bottom |
| Chat | NEW: bottom drawer (closed / peeking / expanded) |
| Profile / Shop / Settings / Events | **Out of scope V1** — link to original site |
Out: voice, native push, anything needing permissions.
## Architecture (TL;DR)
1. **`inject.css`** — mobile overrides for the site's existing CSS variables
2. **`detector.ts`** — `MutationObserver` watches for `.bb-room-overlay`,
route changes
3. **`layout.ts`** — reflows outer shell, mounts drawer host
4. **`chat-drawer.ts`** — bottom sheet UI + chat send-bridge
The chat-drawer sends messages by **dispatching synthetic events on the
site's own chat textarea + button**. The server-side auth, rate limit,
mute, and profanity filters all stay intact — we don't poke React state.
## Dev (no build step in V1)
```sh
# Load unpacked extension
# 1. Open chrome://extensions
# 2. Toggle Developer Mode
# 3. "Load unpacked" -> select extension/
# 4. Visit https://beat-battle.net
```
For Firefox: `about:debugging#/runtime/this-firefox` → "Load Temporary
Add-on" → pick `extension/manifest.json`.
## Tech
- Vanilla TypeScript
- esbuild for the bundle (one-time build, no HMR for V1)
- MV3 (works in Chromium + Firefox 109+)
## Repo
This project will be pushed to `git.molberg.cloud/bonzi/beat-pocket` once
the vault session is unlocked.
## License
Private until first public release.
+274
View File
@@ -0,0 +1,274 @@
# Beat Pocket — Plan
A browser extension (Manifest V3, Chromium + Firefox) that gives
[beat-battle.net](https://beat-battle.net) a mobile-first UI without modifying
the site itself. **We never modify the site's code or call its private API —
we only restyle the DOM it gives us, splice in our own components, and relay
chat through the user's existing authenticated session.**
---
## 1. Why an extension (recap, briefly)
| Option | Verdict | Why |
|---|---|---|
| **MV3 extension** ✅ | **chosen** | Browser already has the session cookie, no OAuth work, zero ToS risk, ships in days |
| Separate web app scraping HTML | reject | Only public API is `GET /api/online-players`; everything else is server-rendered RSC behind server actions. ToS forbids scraping. |
| Native Android app | later | Same browser-restyling problem, slower to ship, Play Store friction |
| iOS Safari extension | later | MV3 extension is API-compatible; ship web first, iOS only if we hit Safari-only users |
Their `robots.txt` blocks AI scrapers but explicitly allows general crawlers
(`User-agent: * / Allow: /`), and a **user-installed extension acting on the
user's own browser session** is a different category from scraping.it can't be on a
blacklisted-domain list per CWS policy).
---
## 2. What we know about the site (from static analysis, no account)
- **Stack:** Next.js 15 App Router on Cloudflare (`x-powered-by: Next.js`)
- **Auth:** [`better-auth`](https://better-auth.com) — Google + Discord via
`signIn.social({ provider, callbackURL })`. Session cookie is HttpOnly, so
no JS access. Endpoint pattern: `/api/auth/*`, server action
`/api/discord/join-complete`.
- **Homepage (`/`) i18n keys we found:**
`MainMenu: { play, freeplay, ranked, custom, lobby, events, shop, profile, ... }`
— confirms **freeplay / ranked / custom / lobby** are real gameplay entry
points.
- **In-game shell class:** `bb-room-overlay` — fixed inset-0 overlay that
already has `overflow-y-auto`, `overscroll-contain`, viewport padding
`px-4 pt-6 pb-14`. **The author knew mobile was hard — they put overflow
scrolling in. The damage is in the inner layout.**
- **Realtime channel (extracted from the compiled JS, not from network):**
- WebSocket reducer with action types
`room.chat.history` / `room.chat.message` /
`LOBBY_CHAT_SEND_OPTIMISTIC` / `RECEIVE_CHAT_HISTORY` /
`RECEIVE_CHAT_MESSAGE`
- Round / phase events `room.phase``browse | lobby | in_game`
- Player events `playerJoin`, `playerLeave`, `MATCH_NAVIGATION_STARTED`
- **Robot policy:** `search=yes, ai-train=no, use=reference` — fine for us;
we're not scraping with a bot, we're modifying what the user already sees.
- **CSS variables in use:** `--bb-accent`, `--bb-bg`, `--bb-surface`,
`--bb-border`, `--bb-ink`, `--bb-muted`, `--bb-muted-strong`,
`--bb-shadow`. They're already on a design-token system, which is a gift
for us — we inherit their theme.
We **don't** know the in-game DOM structure yet (login-gated). V1 will have
to discover it dynamically with a `MutationObserver`.
---
## 3. Architecture
### 3.1 Components (5 files)
```
extension/
├── manifest.json # MV3, scopes & matches
├── background.js # service worker, web navigation listener
├── content/
│ ├── inject.css # mobile-first overrides for the site CSS
│ ├── detector.js # MutationObserver: finds bb-room-overlay, lobby, etc.
│ ├── layout.js # restructures outer layout: <main>, bottom drawer, FAB
│ ├── chat-drawer.js # reads chat DOM, opens WS hook, swaps a panel in
│ └── icons/ # 16/32/48/128 PNGs
└── README.md
```
### 3.2 Run-time flow
```
[beat-battle.net page]
[MATCH chrome:// extension loaded on this URL]
inject.css applied ←────────────────────────────┐
│ │
▼ │
[detector.js] ── MutationObserver on document.body │
detects: │
• .bb-room-overlay (in-game) │
• .bb-footer-nav-main-fade (home menu) │
• route change (pushState via patchHistoryState) │
│ │
▼ │
[layout.js] runs: │
1. Hide desktop-only side panels (display:none) │
2. Re-flow main grid to 1 column │
3. Inject FAB ("💬") bottom-right for chat │
4. Mount <bbp-drawer-host> at body end │
│ │
▼ │
[chat-drawer.js] only when in-game: │
1. Snapshot DOM of chat list (clone, don't move) │
2. Wrap WebSocket to intercept {type:"room.chat.send"} messages
and re-emit through the site's own React reducer (proxy.send)
3. Render in <bbp-drawer>, swipe-up to expand │
│ │
└──── re-attach on every route change ───────┘
```
### 3.3 Selector strategy (defensive)
Beat-battle uses Tailwind — class names are stable but utility-built. We
don't rely on those. We rely on:
1. **Stable semantic hooks** the dev used:
- `bb-room-overlay` (their own naming, low-renaming risk)
- `bb-menu-item`, `bb-login-chip`, `bb-footer-nav-main-fade`
- `data-` attributes when we find them (they use very few — we'll add
some via `MutationObserver` for the bits we need)
2. **Text fingerprinting** as a fallback (DOM nodes containing exact i18n
strings like `"PLAY"` / `"CREATE ROOM"` / `"LOG IN WITH DISCORD"`)
3. **Structural position** as last resort (`body > div:nth-child(N) > …`)
Every selector has a fallback chain:
```js
const chatInput =
document.querySelector('.bb-chat-input') ||
document.querySelector('textarea[placeholder*="chat" i]') ||
document.querySelector('[aria-label*="message" i]') ||
[...document.querySelectorAll('textarea')].find(t => /chat|message|comment/i.test(t.placeholder));
```
---
## 4. V1 scope (the actual mobile pain)
| Screen | What we do |
|---|---|
| **Home (`/`)** | Single-column menu. Big thumb-sized buttons. Login chip pinned top-right (already in their nav, just unstick it for mobile). Hide desktop leaderboard sidebar on `< 768px`. |
| **Lobby list** | Card per lobby, full-width, drag-to-refresh. |
| **In-game room** | Player list at the top (horizontal scroll on mobile). Score/timer pinned to top-safe. Kit / upload / vote actions always visible (sticky bottom action bar). |
| **Chat drawer** | New: a **bottom sheet**, hidden by default, pulls up over the game when the FAB is tapped. States: closed → peeking (last message preview) → expanded (full scrollback + input). Sends through the sites WS so messages count. |
| **Settings/profile/events/shop** | Out of scope for V1. Desktop-equivalent — link the user to the original page in the same tab and don't restyle. |
Things we explicitly **don't** do in V1:
- Affective style matching (their site is a "brutalist" red-grid; we keep it)
- Native push, voice chat, anything requiring permissions
- A settings page (no point until V2)
- iOS Safari — Chromium only for V1
---
## 5. Chat drawer — how sending works (the tricky bit)
Their chat send is React-internal: an action `LOBBY_CHAT_SEND_OPTIMISTIC`
that fires before the WebSocket round-trip. We can't reach into React's
state, but we **can** make the existing textarea + send button work in the
drawer's expanded mode:
1. **Read:** the existing chat scrollback is a DOM node we can either:
- **(a) Clone** its contents into our drawer (read-only display), OR
- **(b) Move** the node into our drawer (write-through). We do (a) — it's
less invasive and the original UI still works if the user closes the
drawer.
2. **Send:** instead of cloning the React send flow, we
- Place a `<textarea>` inside our drawer
- On submit, **dispatch a real keyboard event** on the original site's
chat textarea + a click on its submit button (or `Enter` keydown). This
keeps every server-side guard intact (rate limit, mute, profanity filter).
3. As a backup, we'll **wrap `WebSocket.prototype.send`** at the page level
and **proxy the `room.chat.send` message type** — if the UI send fails we
can fall back to direct socket write. Only enable this if the dispatch-
event path doesn't fire.
The drawer closes if the user navigates to another route (history change).
---
## 6. Tech choices
- **Vanilla TS + esbuild**, no React. A browser extension content script
with React in it is doable but pointless overhead — DOM widgets only.
- **No external CDN deps.** Self-contained. SFX, fonts, icons bundled.
- **Build pipeline:** `esbuild extension/content/*.ts → dist/*.js`, then
zip the dist for `.zip` upload to Chrome Web Store.
- **Manifest V3** for Chromium. Firefox accepts MV3 too (Manifest V3 is
fully supported in FF 109+), so a single manifest works for both.
- **`chrome.storage.sync`** for any tiny per-user prefs (chat drawer collapsed
by default, etc.) — no remote config.
- **Permissions:** `storage`, `*://beat-battle.net/*` host permission. No
`tabs`, no `webRequest`, no `scripting` for any domain other than the one
we explicitly match. (Web Store review will appreciate this minimal
surface.)
---
## 7. Files & approximate LOC
```
extension/manifest.json 30 lines
extension/background.js 25 (very little — reload on update)
extension/content/inject.css 200 (mobile overrides)
extension/content/detector.ts 90 (route + overlay observer)
extension/content/layout.ts 220 (menu reflow, in-game shell restructure)
extension/content/chat-drawer.ts 280 (drawer + send-bridge)
extension/content/util/selectors.ts 80
extension/content/util/ws-proxy.ts 60
────────────────────────────────────
~985 LOC total
1 weekend for V1
```
---
## 8. V2 / future (NOT in V1)
- Spotify/YouTube kit import (if their beat-making flow accepts URL refs)
- Theme matching their `bb-accent` (read CSS vars, mirror into drawer)
- iOS Safari port (uses WebExtension API subset)
- Native app wrapper for Play Store
- Friend-list integration (if they expose one)
---
## 9. Risks & open questions
1. **Their WS auth.** A browser extension on a `.net` origin shares cookies.
If chat requires the WS session cookie, our send-event will work because
**the same browser session is making the WS call from the page, not from
our extension code**. If they bind WS to a per-tab token (we couldn't
confirm either way in the JS dump), we'd need to extract that token via a
`MutationObserver` on the WS handshake. **Action:** log everything the
page sends on first send, verify it's cookie-auth-only.
2. **Class name churn.** Tailwind classes change on every commit. Mitigation:
selectors prioritize text content, structural position, semantic
attributes — not utility classes.
3. **CWS review.** Naming: "Beat Pocket", tiny description. Privacy
disclosures: we don't collect any data (only `chrome.storage.sync`,
none of it leaves the device). Should be a smooth pass.
4. **ToS.** "No reverse engineering, no automated scraping" — we're not
doing either. We restyle what the user is already viewing. Will add a
short statement in the privacy tab of the CWS listing making this clear.
5. **Server action drift.** If they change `LOBBY_CHAT_SEND_OPTIMISTIC`'s
contract, our event-dispatch send breaks. Mitigation: the WS-proxy
fallback in §5.
6. **iOS Safari.** Not in V1; revisit after V1 ships and we have users.
---
## 10. After you approve
I'll spin up:
1. The directory tree (already done — `/root/.openclaw/workspace/projects/beat-pocket/`)
2. `extension/manifest.json`
3. The five content scripts in TS
4. A README with **install-from-source** instructions (load unpacked) so you
can try it before CWS submission
5. A small `dev-server` esbuild watch task + a `package.json`
6. Smoke-test on `load unpacked` using Chromium DevTools mobile emulation
Then we can decide together whether V1 covers the real pain or whether we
still need [specific thing you tell me when you test it].
No code written yet — waiting on your go.
+29
View File
@@ -0,0 +1,29 @@
// esbuild config — bundles TS entry to a single content script
import * as esbuild from 'esbuild';
import { existsSync, mkdirSync } from 'node:fs';
const isWatch = process.argv.includes('--watch');
const outdir = 'dist/content';
if (!existsSync(outdir)) mkdirSync(outdir, { recursive: true });
const ctx = await esbuild.context({
entryPoints: ['extension/content/index.ts'],
bundle: true,
format: 'iife',
target: 'chrome109',
outfile: `${outdir}/index.js`,
sourcemap: 'linked',
logLevel: 'info',
banner: { js: '/* Beat Pocket content script */' },
});
if (isWatch) {
await ctx.watch();
console.log('esbuild watching…');
} else {
const r = await ctx.rebuild();
await ctx.dispose();
if (r.errors.length) process.exit(1);
console.log('build complete');
}
+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/*"]
}
+496
View File
@@ -0,0 +1,496 @@
{
"name": "beat-pocket",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beat-pocket",
"version": "0.1.0",
"devDependencies": {
"@types/chrome": "^0.0.270",
"esbuild": "^0.21.0",
"typescript": "^5.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@types/chrome": {
"version": "0.0.270",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.270.tgz",
"integrity": "sha512-ADvkowV7YnJfycZZxL2brluZ6STGW+9oKG37B422UePf2PCXuFA/XdERI0T18wtuWPx0tmFeZqq6MOXVk1IC+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filesystem": "*",
"@types/har-format": "*"
}
},
"node_modules/@types/filesystem": {
"version": "0.0.36",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filewriter": "*"
}
},
"node_modules/@types/filewriter": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/har-format": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "beat-pocket",
"version": "0.1.0",
"private": true,
"description": "Mobile-first browser extension for beat-battle.net",
"scripts": {
"build": "node esbuild.config.mjs",
"watch": "node esbuild.config.mjs --watch",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/chrome": "^0.0.270",
"esbuild": "^0.21.0",
"typescript": "^5.4.0"
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noEmit": true,
"types": ["chrome"]
},
"include": ["extension/**/*.ts"],
"exclude": ["dist", "node_modules"]
}