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,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.zip
|
||||
*.pem
|
||||
*.key
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
@@ -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
@@ -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 site’s 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.
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
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 |
@@ -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/*"]
|
||||
}
|
||||
Generated
+496
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user