Lesss gooo claude
This commit is contained in:
@@ -141,3 +141,7 @@ dist
|
|||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
.vite/
|
.vite/
|
||||||
|
|
||||||
|
# WXT
|
||||||
|
.wxt/
|
||||||
|
*.zip
|
||||||
|
|||||||
@@ -4,7 +4,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Repository state
|
## Repository state
|
||||||
|
|
||||||
This repo currently contains only a spec (`NEXA_SPEC.md`), `README.md`, and `LICENSE` — no source code, `package.json`, or `manifest.json` exist yet. There are no build/lint/test commands to run because nothing has been scaffolded. When implementation begins, this file should be updated with the actual commands (build, lint, test, load-unpacked instructions) once a package manager and bundler are chosen.
|
Scaffolded with [WXT](https://wxt.dev) (Vite-based, TypeScript-first, built on top of `webextension-polyfill` for the cross-browser `browser.*` API) — the popular framework for building Manifest V3 extensions that target both Firefox and Chromium from one codebase. Package manager: npm.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
- `npm install` — install deps (also runs `wxt prepare` via `postinstall` to generate `.wxt/` types).
|
||||||
|
- `npm run dev` — dev build + watch, targets Chromium by default.
|
||||||
|
- `npm run dev:firefox` — dev build + watch targeting Firefox (use this for Zen).
|
||||||
|
- `npm run build` / `npm run build:firefox` — production build, output in `.output/chrome-mv3/` or `.output/firefox-mv3/`.
|
||||||
|
- `npm run compile` — `tsc --noEmit` type-check only.
|
||||||
|
- `npm run zip` / `npm run zip:firefox` — production build packaged as a `.zip` for store upload.
|
||||||
|
|
||||||
|
Load-unpacked: run a build, then in Chromium go to `chrome://extensions` → enable Developer Mode → "Load unpacked" → select `.output/chrome-mv3/`. In Firefox/Zen go to `about:debugging#/runtime/this-firefox` → "Load Temporary Add-on" → select any file inside `.output/firefox-mv3/` (or use `npm run dev:firefox`, which auto-opens a temporary profile with the extension loaded).
|
||||||
|
|
||||||
|
There is no lint/test setup yet — add one when the project needs it rather than pre-emptively scaffolding.
|
||||||
|
|
||||||
## What this project is
|
## What this project is
|
||||||
|
|
||||||
|
|||||||
Generated
+3101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "nexa-extension",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Nexa browser extension frontend — your blockchain powered agent to help with your trading emotions.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wxt",
|
||||||
|
"dev:firefox": "wxt -b firefox",
|
||||||
|
"build": "wxt build",
|
||||||
|
"build:firefox": "wxt build -b firefox",
|
||||||
|
"zip": "wxt zip",
|
||||||
|
"zip:firefox": "wxt zip -b firefox",
|
||||||
|
"compile": "tsc --noEmit",
|
||||||
|
"postinstall": "wxt prepare"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"wxt": "^0.21.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { browser } from 'wxt/browser';
|
||||||
|
import { DEFAULT_LOCK_STATE, type LockState } from '@/shared/types';
|
||||||
|
import { LOCK_STATE_STORAGE_KEY, type NexaMessage } from '@/shared/messaging';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single isolated seam for lock state. Nothing else in the extension reads or
|
||||||
|
* writes lock state directly — this is where the real backend WebSocket
|
||||||
|
* client plugs in later (replacing the storage-backed mock below) without
|
||||||
|
* any other module needing to change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let currentLockState: LockState = DEFAULT_LOCK_STATE;
|
||||||
|
let initialized: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function loadPersisted(): Promise<void> {
|
||||||
|
return browser.storage.local.get(LOCK_STATE_STORAGE_KEY).then((stored) => {
|
||||||
|
const persisted = stored[LOCK_STATE_STORAGE_KEY] as LockState | undefined;
|
||||||
|
if (persisted) currentLockState = persisted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureInitialized(): Promise<void> {
|
||||||
|
if (!initialized) initialized = loadPersisted();
|
||||||
|
return initialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLockState(): Promise<LockState> {
|
||||||
|
await ensureInitialized();
|
||||||
|
return currentLockState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLockState(next: LockState): Promise<void> {
|
||||||
|
await ensureInitialized();
|
||||||
|
currentLockState = next;
|
||||||
|
await browser.storage.local.set({ [LOCK_STATE_STORAGE_KEY]: next });
|
||||||
|
await broadcastLockState(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function broadcastLockState(state: LockState): Promise<void> {
|
||||||
|
const message: NexaMessage = { type: 'nexa:lock-state-changed', state };
|
||||||
|
|
||||||
|
const tabs = await browser.tabs.query({});
|
||||||
|
await Promise.allSettled(
|
||||||
|
tabs
|
||||||
|
.filter((tab): tab is typeof tab & { id: number } => tab.id != null)
|
||||||
|
.map((tab) => browser.tabs.sendMessage(tab.id, message).catch(() => undefined)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also notify any open extension surfaces (e.g. the popup) listening via runtime messages.
|
||||||
|
await browser.runtime.sendMessage(message).catch(() => undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Contract every site adapter implements. Adapters only know how to *find*
|
||||||
|
* buy elements on their site — they never decide whether to lock, and never
|
||||||
|
* apply any visual treatment themselves.
|
||||||
|
*/
|
||||||
|
export interface SiteAdapter {
|
||||||
|
/** Unique identifier, e.g. the site's hostname. */
|
||||||
|
id: string;
|
||||||
|
/** Whether this adapter applies to the given page URL. */
|
||||||
|
matches(url: string): boolean;
|
||||||
|
/** All current buy-action DOM nodes on the page (or within `root`). */
|
||||||
|
findBuyElements(root: ParentNode): HTMLElement[];
|
||||||
|
/** Stable ancestor of `el` to apply lock styling to, so the whole control is styled. */
|
||||||
|
getContainer(el: HTMLElement): HTMLElement;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { SiteAdapter } from './adapter-interface';
|
||||||
|
|
||||||
|
/** Matches "Buy", "Buy pixelcat", etc. — never a hardcoded token name. */
|
||||||
|
const NOOB_MODE_BUY_TEXT = /^Buy\s/i;
|
||||||
|
|
||||||
|
function findQuickBuyContainers(root: ParentNode): HTMLElement[] {
|
||||||
|
// The reliable anchor is `.buy-click-container` itself, not a fixed
|
||||||
|
// ancestor depth from the inner pill — DOM depth here isn't stable.
|
||||||
|
return Array.from(root.querySelectorAll<HTMLElement>('.buy-click-container'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNoobModeBuyButtons(root: ParentNode): HTMLElement[] {
|
||||||
|
return Array.from(root.querySelectorAll<HTMLElement>('button.bg-increase')).filter((button) =>
|
||||||
|
NOOB_MODE_BUY_TEXT.test(button.textContent?.trim() ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const axiomAdapter: SiteAdapter = {
|
||||||
|
id: 'axiom.trade',
|
||||||
|
|
||||||
|
matches(url) {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.endsWith('axiom.trade');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
findBuyElements(root) {
|
||||||
|
return [...findQuickBuyContainers(root), ...findNoobModeBuyButtons(root)];
|
||||||
|
},
|
||||||
|
|
||||||
|
getContainer(el) {
|
||||||
|
// Quick Buy pills are already `.buy-click-container` (closest() matches
|
||||||
|
// the element itself). Noob Mode buttons have no such ancestor, so the
|
||||||
|
// button itself is the stable container to style.
|
||||||
|
return el.closest<HTMLElement>('.buy-click-container') ?? el;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { browser } from 'wxt/browser';
|
||||||
|
import { axiomAdapter } from './adapters/axiom';
|
||||||
|
import type { SiteAdapter } from './adapters/adapter-interface';
|
||||||
|
import { blurDisable } from './interventions/blur-disable';
|
||||||
|
import { fullBlock } from './interventions/full-block';
|
||||||
|
import type { Intervention } from './interventions/intervention-interface';
|
||||||
|
import { observeForElements } from './observer';
|
||||||
|
import { showLockToast, hideLockToast } from '@/notifications/toast';
|
||||||
|
import { DEFAULT_LOCK_STATE, type LockState } from '@/shared/types';
|
||||||
|
import type { NexaMessage } from '@/shared/messaging';
|
||||||
|
|
||||||
|
// Registering a new site = adding it here. Nothing below this line needs to
|
||||||
|
// change to support another adapter.
|
||||||
|
const adapters: SiteAdapter[] = [axiomAdapter];
|
||||||
|
|
||||||
|
// Registering a new intervention = adding it here (and to LockState['scope']).
|
||||||
|
// Nothing below this line needs to change to support another intervention.
|
||||||
|
const interventionsByScope: Record<LockState['scope'], Intervention> = {
|
||||||
|
'buy-only': blurDisable,
|
||||||
|
'full-block': fullBlock,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wires an adapter + intervention set + background messages together for the current page. */
|
||||||
|
export function initContentScript(): void {
|
||||||
|
const matchedAdapter = adapters.find((candidate) => candidate.matches(window.location.href));
|
||||||
|
if (!matchedAdapter) return;
|
||||||
|
// Re-bound with a non-optional type so nested closures below don't lose
|
||||||
|
// the narrowing TS can't carry across function boundaries.
|
||||||
|
const adapter: SiteAdapter = matchedAdapter;
|
||||||
|
|
||||||
|
let currentState: LockState = DEFAULT_LOCK_STATE;
|
||||||
|
const lockedContainers = new Set<HTMLElement>();
|
||||||
|
|
||||||
|
const activeIntervention = () => interventionsByScope[currentState.scope];
|
||||||
|
|
||||||
|
const onLockedInteraction = (reason: string | undefined) => {
|
||||||
|
showLockToast(reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
function reconcile(buyElements: HTMLElement[]) {
|
||||||
|
if (!currentState.locked) return;
|
||||||
|
|
||||||
|
const containers = new Set(buyElements.map((el) => adapter.getContainer(el)));
|
||||||
|
|
||||||
|
for (const container of containers) {
|
||||||
|
if (!lockedContainers.has(container)) {
|
||||||
|
activeIntervention().apply(container, currentState.reason, onLockedInteraction);
|
||||||
|
lockedContainers.add(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Containers that unmounted (e.g. SPA route change) leave the DOM with
|
||||||
|
// their overlay attached; just stop tracking them, nothing to restore.
|
||||||
|
for (const container of lockedContainers) {
|
||||||
|
if (!containers.has(container) && !container.isConnected) {
|
||||||
|
lockedContainers.delete(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLockState(next: LockState) {
|
||||||
|
const scopeChanged = next.scope !== currentState.scope;
|
||||||
|
const wasLocked = currentState.locked;
|
||||||
|
|
||||||
|
if (wasLocked && (!next.locked || scopeChanged)) {
|
||||||
|
const previousIntervention = interventionsByScope[currentState.scope];
|
||||||
|
for (const container of lockedContainers) {
|
||||||
|
previousIntervention.remove(container);
|
||||||
|
}
|
||||||
|
lockedContainers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasLocked && !next.locked) {
|
||||||
|
hideLockToast();
|
||||||
|
}
|
||||||
|
|
||||||
|
currentState = next;
|
||||||
|
|
||||||
|
if (next.locked) {
|
||||||
|
showLockToast(next.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcile(adapter.findBuyElements(document.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = observeForElements(document.body, () => adapter.findBuyElements(document.body), reconcile);
|
||||||
|
|
||||||
|
browser.runtime
|
||||||
|
.sendMessage({ type: 'nexa:get-lock-state' } satisfies NexaMessage)
|
||||||
|
.then((state: LockState) => applyLockState(state))
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener((message: NexaMessage) => {
|
||||||
|
if (message?.type === 'nexa:lock-state-changed') {
|
||||||
|
applyLockState(message.state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('pagehide', () => handle.disconnect(), { once: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Intervention } from './intervention-interface';
|
||||||
|
|
||||||
|
interface LockedRecord {
|
||||||
|
originalStyleAttr: string | null;
|
||||||
|
overlay: HTMLElement;
|
||||||
|
onOverlayClick: (event: MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracked outside the DOM so remove() can restore each container exactly,
|
||||||
|
// without relying on any marker class/attribute the site itself might touch.
|
||||||
|
const locked = new WeakMap<HTMLElement, LockedRecord>();
|
||||||
|
|
||||||
|
const LOCK_ICON_SVG =
|
||||||
|
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg>';
|
||||||
|
|
||||||
|
const OVERLAY_STYLE = [
|
||||||
|
'position:absolute',
|
||||||
|
'inset:0',
|
||||||
|
'display:flex',
|
||||||
|
'align-items:center',
|
||||||
|
'justify-content:center',
|
||||||
|
'pointer-events:auto',
|
||||||
|
'cursor:not-allowed',
|
||||||
|
'z-index:2147483000',
|
||||||
|
'filter:none',
|
||||||
|
'opacity:1',
|
||||||
|
'color:#fff',
|
||||||
|
'background:transparent',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1 default visual treatment: blur + reduced opacity + a non-blurred lock
|
||||||
|
* icon overlay that also acts as a click-catcher. The original container is
|
||||||
|
* never mutated destructively — its full inline `style` attribute is
|
||||||
|
* snapshotted and restored verbatim on remove().
|
||||||
|
*/
|
||||||
|
export const blurDisable: Intervention = {
|
||||||
|
id: 'blur-disable',
|
||||||
|
|
||||||
|
apply(container, reason, onLockedInteraction) {
|
||||||
|
if (locked.has(container)) return;
|
||||||
|
|
||||||
|
const originalStyleAttr = container.getAttribute('style');
|
||||||
|
const computedPosition = getComputedStyle(container).position;
|
||||||
|
|
||||||
|
container.style.filter = 'blur(3px)';
|
||||||
|
container.style.opacity = '0.5';
|
||||||
|
container.style.cursor = 'not-allowed';
|
||||||
|
container.style.pointerEvents = 'none';
|
||||||
|
if (computedPosition === 'static') {
|
||||||
|
// Needed so the overlay below can position itself against this
|
||||||
|
// container instead of the nearest existing positioned ancestor.
|
||||||
|
container.style.position = 'relative';
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.dataset.nexaOverlay = 'true';
|
||||||
|
overlay.style.cssText = OVERLAY_STYLE;
|
||||||
|
overlay.innerHTML = LOCK_ICON_SVG;
|
||||||
|
|
||||||
|
const onOverlayClick = (event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
onLockedInteraction(reason);
|
||||||
|
};
|
||||||
|
overlay.addEventListener('click', onOverlayClick, true);
|
||||||
|
|
||||||
|
container.appendChild(overlay);
|
||||||
|
locked.set(container, { originalStyleAttr, overlay, onOverlayClick });
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(container) {
|
||||||
|
const record = locked.get(container);
|
||||||
|
if (!record) return;
|
||||||
|
|
||||||
|
record.overlay.removeEventListener('click', record.onOverlayClick, true);
|
||||||
|
record.overlay.remove();
|
||||||
|
|
||||||
|
if (record.originalStyleAttr === null) {
|
||||||
|
container.removeAttribute('style');
|
||||||
|
} else {
|
||||||
|
container.setAttribute('style', record.originalStyleAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
locked.delete(container);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Intervention } from './intervention-interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escalation intervention: full-site block. Stub only for v1 — structure so
|
||||||
|
* a future pass can fill in a real full-page overlay without touching
|
||||||
|
* anything outside this file.
|
||||||
|
*/
|
||||||
|
export const fullBlock: Intervention = {
|
||||||
|
id: 'full-block',
|
||||||
|
|
||||||
|
apply(_container, _reason, _onLockedInteraction) {
|
||||||
|
console.warn('[nexa] full-block intervention is a v1 stub and is not yet implemented.');
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(_container) {},
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Contract every intervention strategy implements. Interventions only know
|
||||||
|
* how to visually enforce a lock on a given container — they never detect
|
||||||
|
* buy elements (that's the adapter's job) and never decide lock/unlock
|
||||||
|
* timing (that's the background lock-state seam's job).
|
||||||
|
*/
|
||||||
|
export interface Intervention {
|
||||||
|
/** Unique identifier, e.g. 'blur-disable'. */
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Apply the lock treatment to `container`. Must be idempotent — calling
|
||||||
|
* apply() twice on the same container without an intervening remove()
|
||||||
|
* should not double-apply.
|
||||||
|
*
|
||||||
|
* `onLockedInteraction` should be called whenever the user tries to
|
||||||
|
* interact with the locked container, so the caller can surface the
|
||||||
|
* reason toast.
|
||||||
|
*/
|
||||||
|
apply(container: HTMLElement, reason: string | undefined, onLockedInteraction: (reason: string | undefined) => void): void;
|
||||||
|
/** Reverse apply() on `container`, restoring its original markup exactly. */
|
||||||
|
remove(container: HTMLElement): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface ObserverHandle {
|
||||||
|
disconnect(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic, adapter-agnostic MutationObserver. SPA-safe: re-runs
|
||||||
|
* `findElements` on every DOM mutation under `root` (rather than a one-time
|
||||||
|
* querySelectorAll on load) so elements that mount/unmount client-side are
|
||||||
|
* still caught.
|
||||||
|
*/
|
||||||
|
export function observeForElements(
|
||||||
|
root: Node,
|
||||||
|
findElements: () => HTMLElement[],
|
||||||
|
onChange: (elements: HTMLElement[]) => void,
|
||||||
|
): ObserverHandle {
|
||||||
|
const emit = () => onChange(findElements());
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => emit());
|
||||||
|
observer.observe(root, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
emit();
|
||||||
|
|
||||||
|
return {
|
||||||
|
disconnect() {
|
||||||
|
observer.disconnect();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { getLockState, setLockState } from '@/background/lock-state';
|
||||||
|
import type { NexaMessage } from '@/shared/messaging';
|
||||||
|
|
||||||
|
export default defineBackground(() => {
|
||||||
|
browser.runtime.onMessage.addListener((message: NexaMessage, _sender, sendResponse) => {
|
||||||
|
switch (message?.type) {
|
||||||
|
case 'nexa:get-lock-state':
|
||||||
|
getLockState().then(sendResponse);
|
||||||
|
return true; // keep the message channel open for the async response
|
||||||
|
|
||||||
|
case 'nexa:set-lock-state':
|
||||||
|
setLockState(message.state).then(() => sendResponse({ ok: true }));
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case 'nexa:open-popup':
|
||||||
|
// Best-effort: not all browsers/contexts allow programmatic popup
|
||||||
|
// opening outside a direct user gesture on the action icon.
|
||||||
|
browser.action.openPopup().catch(() => undefined);
|
||||||
|
return false;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { initContentScript } from '@/content-scripts/content-index';
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: ['https://axiom.trade/*'],
|
||||||
|
runAt: 'document_idle',
|
||||||
|
main() {
|
||||||
|
initContentScript();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Nexa</title>
|
||||||
|
<link rel="stylesheet" href="./style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { browser } from 'wxt/browser';
|
||||||
|
import { DEFAULT_LOCK_STATE, type LockScope, type LockState } from '@/shared/types';
|
||||||
|
import type { NexaMessage } from '@/shared/messaging';
|
||||||
|
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
if (!app) throw new Error('Popup root element #app not found.');
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
<header>
|
||||||
|
<h1>Nexa</h1>
|
||||||
|
<p class="subtitle">Trading companion</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="status" id="status"></section>
|
||||||
|
|
||||||
|
<section class="dev-controls">
|
||||||
|
<h2>Dev mock controls</h2>
|
||||||
|
<p class="hint">No backend connected yet — use this to simulate a lock signal.</p>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Locked</span>
|
||||||
|
<input type="checkbox" id="locked-toggle" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Scope</span>
|
||||||
|
<select id="scope-select">
|
||||||
|
<option value="buy-only">buy-only</option>
|
||||||
|
<option value="full-block">full-block</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Reason</span>
|
||||||
|
<input type="text" id="reason-input" placeholder="e.g. -$62 loss crossed your $50 threshold" />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<a class="thresholds-link" id="thresholds-link" href="#" title="Threshold settings are not part of this milestone">
|
||||||
|
Adjust loss thresholds →
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('status') as HTMLElement;
|
||||||
|
const lockedToggle = document.getElementById('locked-toggle') as HTMLInputElement;
|
||||||
|
const scopeSelect = document.getElementById('scope-select') as HTMLSelectElement;
|
||||||
|
const reasonInput = document.getElementById('reason-input') as HTMLInputElement;
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = value;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatus(state: LockState): void {
|
||||||
|
statusEl.innerHTML = state.locked
|
||||||
|
? `<div class="badge badge-locked">🔒 Locked</div>
|
||||||
|
<p class="reason">${escapeHtml(state.reason ?? 'No reason provided.')}</p>
|
||||||
|
<p class="scope">Scope: ${escapeHtml(state.scope)}</p>`
|
||||||
|
: `<div class="badge badge-unlocked">Unlocked</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFormFromState(state: LockState): void {
|
||||||
|
lockedToggle.checked = state.locked;
|
||||||
|
scopeSelect.value = state.scope;
|
||||||
|
reasonInput.value = state.reason ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadState(): Promise<LockState> {
|
||||||
|
const message: NexaMessage = { type: 'nexa:get-lock-state' };
|
||||||
|
const state = (await browser.runtime.sendMessage(message)) as LockState | undefined;
|
||||||
|
return state ?? DEFAULT_LOCK_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushState(): Promise<void> {
|
||||||
|
const next: LockState = {
|
||||||
|
locked: lockedToggle.checked,
|
||||||
|
scope: scopeSelect.value as LockScope,
|
||||||
|
reason: reasonInput.value.trim() || undefined,
|
||||||
|
lockedAt: lockedToggle.checked ? Date.now() : undefined,
|
||||||
|
};
|
||||||
|
const message: NexaMessage = { type: 'nexa:set-lock-state', state: next };
|
||||||
|
await browser.runtime.sendMessage(message);
|
||||||
|
renderStatus(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const el of [lockedToggle, scopeSelect, reasonInput]) {
|
||||||
|
el.addEventListener('change', () => void pushState());
|
||||||
|
}
|
||||||
|
|
||||||
|
loadState().then((state) => {
|
||||||
|
syncFormFromState(state);
|
||||||
|
renderStatus(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener((message: NexaMessage) => {
|
||||||
|
if (message?.type === 'nexa:lock-state-changed') {
|
||||||
|
syncFormFromState(message.state);
|
||||||
|
renderStatus(message.state);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 280px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
font: 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .subtitle {
|
||||||
|
margin: 2px 0 12px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-locked {
|
||||||
|
background: rgba(220, 38, 38, 0.15);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-unlocked {
|
||||||
|
background: rgba(22, 163, 74, 0.15);
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status .reason {
|
||||||
|
margin: 8px 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status .scope {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-controls {
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.25);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-controls h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-controls .hint {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type='text'] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thresholds-link {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.5;
|
||||||
|
text-decoration: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { browser } from 'wxt/browser';
|
||||||
|
import type { NexaMessage } from '@/shared/messaging';
|
||||||
|
|
||||||
|
const TOAST_ID = 'nexa-lock-toast';
|
||||||
|
const STYLE_ID = 'nexa-lock-toast-styles';
|
||||||
|
|
||||||
|
function ensureStyles(): void {
|
||||||
|
if (document.getElementById(STYLE_ID)) return;
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
#${TOAST_ID} {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 2147483647;
|
||||||
|
max-width: 320px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font: 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
#${TOAST_ID} .nexa-toast-icon { flex: none; margin-top: 1px; }
|
||||||
|
#${TOAST_ID} .nexa-toast-body { flex: 1; }
|
||||||
|
#${TOAST_ID} .nexa-toast-title { font-weight: 600; margin-bottom: 2px; }
|
||||||
|
#${TOAST_ID} .nexa-toast-reason { opacity: 0.85; }
|
||||||
|
#${TOAST_ID} .nexa-toast-action {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #7ab8ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#${TOAST_ID} .nexa-toast-action:hover { text-decoration: underline; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureToast(): HTMLElement {
|
||||||
|
ensureStyles();
|
||||||
|
|
||||||
|
let el = document.getElementById(TOAST_ID);
|
||||||
|
if (el) return el;
|
||||||
|
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.id = TOAST_ID;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="nexa-toast-icon">🔒</div>
|
||||||
|
<div class="nexa-toast-body">
|
||||||
|
<div class="nexa-toast-title">Nexa: buying locked</div>
|
||||||
|
<div class="nexa-toast-reason"></div>
|
||||||
|
<div class="nexa-toast-action">View details</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
// Deliberately no close/dismiss control — unlocking is the only way this
|
||||||
|
// toast goes away. See hideLockToast(), called only on an actual unlock.
|
||||||
|
el.querySelector('.nexa-toast-action')?.addEventListener('click', () => {
|
||||||
|
const message: NexaMessage = { type: 'nexa:open-popup' };
|
||||||
|
browser.runtime.sendMessage(message).catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showLockToast(reason: string | undefined): void {
|
||||||
|
const el = ensureToast();
|
||||||
|
const reasonEl = el.querySelector('.nexa-toast-reason');
|
||||||
|
if (reasonEl) {
|
||||||
|
reasonEl.textContent = reason ?? 'Buy actions are temporarily locked.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideLockToast(): void {
|
||||||
|
document.getElementById(TOAST_ID)?.remove();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { LockState } from './types';
|
||||||
|
|
||||||
|
/** browser.storage.local key backing the lock state seam in the background script. */
|
||||||
|
export const LOCK_STATE_STORAGE_KEY = 'nexa:lockState';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed message contracts between background <-> content scripts <-> popup.
|
||||||
|
* All lock-state reads/writes go through these so no module reaches into
|
||||||
|
* storage directly (see src/background/lock-state.ts).
|
||||||
|
*/
|
||||||
|
export type NexaMessage =
|
||||||
|
| { type: 'nexa:get-lock-state' }
|
||||||
|
| { type: 'nexa:set-lock-state'; state: LockState }
|
||||||
|
| { type: 'nexa:lock-state-changed'; state: LockState }
|
||||||
|
| { type: 'nexa:open-popup' };
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type LockScope = 'buy-only' | 'full-block';
|
||||||
|
|
||||||
|
export interface LockState {
|
||||||
|
locked: boolean;
|
||||||
|
reason?: string;
|
||||||
|
scope: LockScope;
|
||||||
|
/** epoch ms when this lock was activated; undefined while unlocked */
|
||||||
|
lockedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LOCK_STATE: LockState = {
|
||||||
|
locked: false,
|
||||||
|
scope: 'buy-only',
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.wxt/tsconfig.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'wxt';
|
||||||
|
|
||||||
|
// See https://wxt.dev/api/config.html
|
||||||
|
export default defineConfig({
|
||||||
|
srcDir: 'src',
|
||||||
|
// Target Manifest V3 on both Chromium and Firefox (modern Firefox / Zen support it).
|
||||||
|
manifestVersion: 3,
|
||||||
|
manifest: {
|
||||||
|
name: 'Nexa',
|
||||||
|
description: 'Your blockchain powered agent to help with your trading emotions.',
|
||||||
|
permissions: ['storage'],
|
||||||
|
host_permissions: ['https://axiom.trade/*'],
|
||||||
|
browser_specific_settings: {
|
||||||
|
gecko: {
|
||||||
|
// Placeholder id for local/dev builds; replace before publishing to AMO.
|
||||||
|
id: '[email protected]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user