Lesss gooo claude
This commit is contained in:
@@ -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',
|
||||
};
|
||||
Reference in New Issue
Block a user