Lesss gooo claude

This commit is contained in:
2026-09-06 11:44:59 +02:00
parent 477db87363
commit 9362c9ac80
22 changed files with 3871 additions and 1 deletions
+4
View File
@@ -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
+13 -1
View File
@@ -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
+3101
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
}
+51
View File
@@ -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;
}
+39
View File
@@ -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;
},
};
+100
View File
@@ -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;
}
+28
View File
@@ -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();
},
};
}
+25
View File
@@ -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;
}
});
});
+9
View File
@@ -0,0 +1,9 @@
import { initContentScript } from '@/content-scripts/content-index';
export default defineContentScript({
matches: ['https://axiom.trade/*'],
runAt: 'document_idle',
main() {
initContentScript();
},
});
+12
View File
@@ -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>
+101
View File
@@ -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);
}
});
+91
View File
@@ -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;
}
+83
View File
@@ -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();
}
+15
View File
@@ -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' };
+14
View File
@@ -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',
};
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.wxt/tsconfig.json"
}
+20
View File
@@ -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]',
},
},
},
});