Compare commits
7
Commits
5cfb0f0b53
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
891d561aaa | ||
|
|
cd56cf60cd | ||
|
|
abdc0c9fca | ||
|
|
c87f86f82e | ||
|
|
1222baadee | ||
|
|
705945bf25 | ||
|
|
9a774a38d4 |
@@ -26,7 +26,7 @@ Osias is a blockchain-powered trading companion focused on the emotional/behavio
|
||||
|
||||
License: Apache 2.0. The backend stays closed-source; this frontend repo is the OSS component.
|
||||
|
||||
The behavioral spec and the backend WebSocket protocol spec (formerly `NEXA_SPEC.md` and `NEXA_PROTOCOL_SPEC.md`) have been folded into this file — this document is now the source of truth for both, not a summary of them.
|
||||
The behavioral spec and the backend WebSocket protocol spec are folded into this file — this document is the source of truth for both, not a summary of them.
|
||||
|
||||
## Target platform
|
||||
|
||||
@@ -62,7 +62,7 @@ Planned structure:
|
||||
intervention-interface.ts
|
||||
observer.ts - generic MutationObserver watching for adapter-declared selectors (SPA-safe)
|
||||
content-index.ts - wires adapter + intervention + background messages together
|
||||
/popup - status, reason for lock, threshold settings link
|
||||
/popup - status, reason for lock, Settings panel (loss thresholds)
|
||||
/notifications - toast/banner injected into page or via browser notification API
|
||||
/shared
|
||||
messaging.ts - typed message contracts between background <-> content scripts <-> popup
|
||||
@@ -81,21 +81,21 @@ A site adapter's contract: `matches` (URL pattern), `findBuyElements()`, `getCon
|
||||
|
||||
## Lock state contract
|
||||
|
||||
The lock/unlock state is a simple typed interface: `{ locked: boolean, reason?: string, scope: 'buy-only' | 'full-block' }`. **Implemented**: the background script (`src/background/lock-state.ts`) exposes the single isolated seam (`getLockState()` / `applyLockState()`) — nothing else reads or writes lock state directly. `applyLockState()` is called exclusively by the real WS client (`src/background/ws-client.ts`) on an incoming `lock_state` message; there is no mock/dev toggle anymore since a real backend exists (the popup used to have one — removed once the backend connection landed).
|
||||
The lock/unlock state is a simple typed interface: `{ locked: boolean, reason?: string, scope: 'buy-only' | 'full-block' }`. The background script (`src/background/lock-state.ts`) exposes the single isolated seam (`getLockState()` / `applyLockState()`) — nothing else reads or writes lock state directly. `applyLockState()` is called exclusively by the real WS client (`src/background/ws-client.ts`) on an incoming `lock_state` message; there is no mock/dev toggle — lock state is driven exclusively by the real backend connection.
|
||||
|
||||
Note the naming mismatch with the wire protocol below: the frontend's internal `LockScope` uses kebab-case (`'buy-only' | 'full-block'`), while the wire protocol (§ "WebSocket protocol") uses snake_case (`"buy_only" | "full_block"`). `ws-client.ts` is exactly where that translation happens — not leaked into content scripts or the popup.
|
||||
|
||||
## Backend connection (implemented)
|
||||
## Backend connection
|
||||
|
||||
- `src/shared/config.ts` — `BACKEND_HTTP_URL`/`BACKEND_WS_URL`, pointed at the real deployed backend (`api.osias.trade`, TLS). `host_permissions` in `wxt.config.ts` must stay in sync with whatever host is configured here.
|
||||
- The Firefox-only CSP override that used to live in `wxt.config.ts` (working around Firefox upgrading a plaintext `ws://` dev connection to `wss://`) has been removed now that the backend is real `wss://` behind TLS — `upgrade-insecure-requests` has nothing to upgrade. Re-add it, scoped to `browser === 'firefox'`, only if a plaintext dev backend comes back into the loop.
|
||||
- There's no Firefox-specific CSP override in `wxt.config.ts` — not needed while the backend is real `wss://` behind TLS (`upgrade-insecure-requests` has nothing to upgrade). Re-add one, scoped to `browser === 'firefox'`, only if a plaintext dev backend comes back into the loop (Firefox otherwise upgrades a plaintext `ws://` dev connection to `wss://`, breaking it).
|
||||
- `src/background/backend-client.ts` — session storage only (`getSessionToken()`/`getStoredSession()`/`storeSession(token, walletAddress)`/`clearSessionToken()`). Stores the wallet address alongside the token, not just the token — see "Session storage tracks which wallet it belongs to" below for why. Getting a token in the first place is `wallet-auth.ts`'s job.
|
||||
- `src/background/wallet-auth.ts` — `handleWalletConnected()` runs the REST auth flow (`POST /auth/nonce` → Phantom signature → `POST /auth/verify` → session token) once a content script reports a connected wallet. Also `requestWalletReconnect()` (silent reconnect after the backend invalidates a session), `requestWalletDisconnect()` (sign-out), `requestAccountSwitch()` (explicit account switch) — all three just message whichever tabs are on a supported site; `wallet-connect.ts` in the content script does the actual work.
|
||||
- `src/background/ws-client.ts` — the WS client described above: connects to `/ws?token=...`. `connectWsClient()` returns a controller with `reconnectNow()` so the background script can short-circuit the backoff wait right after a fresh token arrives. Auth failures (`4001` close, `auth_expired`/`session_revoked` errors) do **not** auto-retry with backoff — they call `onAuthExpired()` instead, since retrying with a known-bad token can't succeed; only real disconnects (network drop, backgrounded browser) use the protocol's suggested backoff schedule.
|
||||
- `src/background/ws-client.ts` — the WS client described above: connects to `/ws?token=...`. `connectWsClient()` returns a controller with `reconnectNow()` so the background script can short-circuit the backoff wait right after a fresh token arrives. Auth failures (`4001` close, `auth_expired`/`session_revoked` errors) do **not** auto-retry with backoff — they call `onAuthExpired()` instead, since retrying with a known-bad token can't succeed; only real disconnects (network drop, backgrounded browser) use the protocol's suggested backoff schedule. Also sends a `{"type":"ping"}` every 20s while connected (`setInterval`, cleared on close) — see the wire protocol's `ping` entry below for why this matters specifically for a MV3 background service worker, and `backend/CLAUDE.md`'s "Loss detection" for the reconnect-durability bug this keepalive helps avoid triggering in the first place (fixed server-side, but avoiding the disconnect is still worth doing).
|
||||
- `src/background/connection-status.ts` — separate from lock state; the popup surfaces this (connecting/connected/disconnected/auth-error) alongside the lock state so a broken connection isn't silently indistinguishable from "unlocked".
|
||||
- `src/background/settings-client.ts` — `GET`/`PATCH /me/settings` (per-user loss-detection thresholds; see backend/CLAUDE.md, "Account settings"), same direct-fetch-with-bearer-token shape as `wallet-auth.ts`. Popup -> background dispatch is `osias:get-settings`/`osias:update-settings` in `background.ts`, responses shaped `{ settings } | { error }` (`SettingsResult` in `shared/messaging.ts`). The popup's "Loss thresholds" panel (`entrypoints/popup/App.tsx`) is the only place percent fields (`near_full_exit_fraction` and both loss thresholds) get converted between the backend's fractional wire representation and the whole-number percentages shown in the form — same "convert only at the seam" rule as `LockScope`'s kebab/snake split below.
|
||||
- `src/background/settings-client.ts` — `GET`/`PATCH /me/settings` (per-user loss-detection thresholds; see backend/CLAUDE.md, "Account settings"), same direct-fetch-with-bearer-token shape as `wallet-auth.ts`. Popup -> background dispatch is `osias:get-settings`/`osias:update-settings` in `background.ts`, responses shaped `{ settings } | { error }` (`SettingsResult` in `shared/messaging.ts`). Settings are fetched eagerly in `App`'s mount effect (same pattern as lock state/connection status) rather than lazily on first expand, so a load/save error surfaces immediately as a dismissible banner on the popup's main screen instead of being invisible until the user opens the collapsed "Settings" panel (`entrypoints/popup/App.tsx`'s `SettingsPanel`, a purely presentational form fed by `App`'s state). That panel is the only place percent fields (`near_full_exit_fraction` and both loss thresholds) get converted between the backend's fractional wire representation and the whole-number percentages shown in the form — same "convert only at the seam" rule as `LockScope`'s kebab/snake split above.
|
||||
|
||||
### Wallet auth (Phantom) — implemented
|
||||
### Wallet auth (Phantom)
|
||||
|
||||
Real wallet signing, not a stub keypair. Phantom (and any wallet injecting a compatible `window.solana`) is only reachable from a **page's own JS world** — a normal (isolated-world) content script cannot call into it directly, hence the two-content-script bridge below. This is the standard pattern for extensions that need to talk to page-injected wallet providers.
|
||||
|
||||
@@ -104,13 +104,13 @@ Real wallet signing, not a stub keypair. Phantom (and any wallet injecting a com
|
||||
- `src/content-scripts/wallet-bridge/banner.ts` — minimal plain-DOM "Connect your wallet to Osias" prompt injected onto the page (bottom-right, fixed position) when a wallet isn't already trusted for this origin. No framework, kept deliberately tiny since it's living on someone else's page.
|
||||
- `src/content-scripts/wallet-connect.ts` — orchestrates the above, wired into `content-index.ts` (runs independently of site-adapter matching): on load, tries `connect({ onlyIfTrusted: true })` silently (succeeds with no user interaction if the user already approved this origin in Phantom before); on failure, shows the banner and only calls plain `connect()` from the banner button's own click handler, since **a real user gesture is required for Phantom to show its approval popup on a first-ever connect** — this is why the banner exists in the page rather than the extension popup (a click in the popup's UI doesn't count as a gesture on the axiom.trade page by the time it reaches the wallet, since it crosses an extension-messaging boundary asynchronously). Once connected, reports `{ walletAddress }` to the background via `osias:wallet-connected`. Also handles, all from the background: `osias:wallet-sign-request` (sign a nonce), `osias:request-wallet-connect` (retry the silent connect, e.g. after a session was invalidated), `osias:wallet-disconnect-request` (sign-out) and `osias:switch-account-request` (explicit account switch) — see the sign-out/switch-account bullet below for those two.
|
||||
- **Known gap**: this only works while an axiom.trade tab is open — there's no wallet connection path from the popup alone. That's intentional for now (matches the "only live while on a supported site" framing in backend/CLAUDE.md's RPC-subscription note), not an oversight.
|
||||
- **Sign out and account switching (implemented) — both explicit user actions, not automatic.** An earlier version of this also wired Phantom's own `accountChanged` provider event to trigger re-auth automatically, but calling `connect()` ourselves also fires that same event — so a normal silent reconnect raced its own event-triggered handler and produced *two* competing "wallet connected" reports, each independently asking Phantom to sign a nonce (a popup every time). Removed entirely; `wallet-bridge/inject.ts` does not listen for any Phantom provider events, only responds to our own explicit calls. See its doc comment for the full story if this is ever reconsidered.
|
||||
- **Sign out and account switching — both explicit user actions, not automatic.** Phantom's own `accountChanged` provider event is deliberately *not* listened for: calling `connect()` ourselves also fires that same event, so a normal silent reconnect would race its own event-triggered handler and produce two competing "wallet connected" reports, each independently asking Phantom to sign a nonce (a popup every time). `wallet-bridge/inject.ts` only responds to our own explicit calls — see its doc comment before reconsidering listening for provider events.
|
||||
- "Sign out" (popup button → `osias:sign-out` → `background.ts`'s `signOut()`) clears the stored session, force-closes the WS connection (`ws-client.ts`'s `disconnect()`, distinct from `reconnectNow()` — it also suppresses auto-reconnect until a new wallet connects), and asks the content script to call `provider.disconnect()`, which revokes Phantom's trust for the origin so the next silent connect correctly fails until the user reconnects.
|
||||
- "Switch account" (popup button → `osias:switch-account` → `wallet-connect.ts`'s `switchAccount()`) explicitly disconnects then immediately reconnects, so Phantom shows its connect approval UI for whichever account is currently active there. Not fully verified whether Phantom requires a fresh user gesture for this non-`onlyIfTrusted` connect call relayed from the popup (vs. a direct page click) — if it does, `switchAccount()` falls back to the on-page banner so the user can complete it with a real click.
|
||||
- `background.ts`'s `osias:wallet-connected` handler distinguishes "already signed in as this wallet" (no-op) from "signed in as a *different* wallet" (re-authenticate) by comparing the reported wallet address against `backend-client.ts`'s stored `{ token, walletAddress }` pair (`storeSession()`, not just a bare token) — a bare "do we have a token" check can't tell those apart and was the reason the account-switch case needed fixing in the first place.
|
||||
- **Firefox gotcha (hit during dev, now fixed)**: the isolated↔main-world bridge originally used `CustomEvent`s dispatched on `window`. That works on Chromium but throws `Uncaught Error: Permission denied to access property "id"` on Firefox — a `CustomEvent.detail` object created in one world can't have its properties read from the other (an Xray-wrapper security restriction specific to Firefox's extension model). Fixed by switching to `window.postMessage` for this bridge, which structured-clones its payload across the boundary correctly on both browsers — the same technique Phantom's own inpage↔content-script bridge uses. If you're extending this bridge, don't reach for `CustomEvent` again for isolated↔main-world data; `postMessage` (with a `channel` field to disambiguate from the page's own postMessage traffic, and an `event.source === window` check) is the pattern here.
|
||||
- `background.ts`'s `osias:wallet-connected` handler distinguishes "already signed in as this wallet" (no-op) from "signed in as a *different* wallet" (re-authenticate) by comparing the reported wallet address against `backend-client.ts`'s stored `{ token, walletAddress }` pair (`storeSession()`, not just a bare token) — a bare "do we have a token" check can't tell those apart, which matters specifically for account switching.
|
||||
- **Firefox gotcha**: the isolated↔main-world bridge originally used `CustomEvent`s dispatched on `window`. That works on Chromium but throws `Uncaught Error: Permission denied to access property "id"` on Firefox — a `CustomEvent.detail` object created in one world can't have its properties read from the other (an Xray-wrapper security restriction specific to Firefox's extension model). Fixed by switching to `window.postMessage` for this bridge, which structured-clones its payload across the boundary correctly on both browsers — the same technique Phantom's own inpage↔content-script bridge uses. If you're extending this bridge, don't reach for `CustomEvent` again for isolated↔main-world data; `postMessage` (with a `channel` field to disambiguate from the page's own postMessage traffic, and an `event.source === window` check) is the pattern here.
|
||||
- **Verified working end-to-end against real Phantom** on Zen: connect → sign → `/auth/verify` → session token stored, confirmed via the `[osias/wallet-*]` debug logs. `world: 'MAIN'` also needs Firefox 128+; confirmed fine on Zen's base version.
|
||||
- **A content script reports `wallet-connected` on every page load** (it always tries a silent `onlyIfTrusted` connect first), and `signMessage()` shows a fresh Phantom approval popup every single time it's called, unlike `connect()`, which is silent once trusted — so the re-auth gate on that message matters a lot for not spamming signature prompts. An earlier version of the gate only checked "is there any token stored," which correctly avoided re-signing on ordinary page loads but broke account switching (see above — it's now a wallet-address comparison instead).
|
||||
- **A content script reports `wallet-connected` on every page load** (it always tries a silent `onlyIfTrusted` connect first), and `signMessage()` shows a fresh Phantom approval popup every single time it's called, unlike `connect()`, which is silent once trusted — so the re-auth gate on that message (the wallet-address comparison above) matters a lot for not spamming signature prompts; a bare "is there any token stored" check would avoid re-signing on ordinary page loads but can't distinguish an actual account switch from a no-op reconnect.
|
||||
|
||||
## WebSocket protocol (backend wire contract)
|
||||
|
||||
@@ -145,6 +145,8 @@ Single source of truth for the wire protocol between the Osias backend (Rust/Axu
|
||||
```
|
||||
Client should close and re-auth on `auth_expired`.
|
||||
|
||||
- `pong` — reply to a client `ping` (below): `{ "type": "pong" }`.
|
||||
|
||||
**Client → server messages**
|
||||
|
||||
- `unlock_request` — user explicitly chose to unlock:
|
||||
@@ -159,7 +161,7 @@ Single source of truth for the wire protocol between the Osias backend (Rust/Axu
|
||||
```
|
||||
`reason` is a short machine-readable code (`circumvention_detected` is the only defined value in v1, kept open-ended for future reasons). `detail` is free-text for logging only, never shown to the user. Accepted escalations get an `ack` followed by a `lock_state` with `scope: "full_block"`.
|
||||
|
||||
- `ping` (optional fallback) — `{ "type": "ping" }`, server replies `{ "type": "pong" }`. Prefer native WebSocket ping/pong frames if the client library supports them; this JSON-level version is only a fallback if it doesn't.
|
||||
- `ping` — `{ "type": "ping" }`, server replies `pong`. The extension sends this every 20s deliberately, not merely as a fallback: it runs in a MV3 background service worker, which Chromium can idle-kill after ~30s with no activity reaching its own message-event handlers, and a JSON application message reliably counts as that activity where a bare WS-level ping/pong frame is not guaranteed to. No client is required to send it, but any client susceptible to its own runtime idling its background process should.
|
||||
|
||||
**Connection lifecycle**
|
||||
1. Client opens `wss://.../ws?token=...`.
|
||||
@@ -176,7 +178,7 @@ Single source of truth for the wire protocol between the Osias backend (Rust/Axu
|
||||
## Visual treatment for v1 (`blur-disable`)
|
||||
|
||||
- Wrap or overlay the original element rather than mutating its classes destructively, so the original markup can be restored exactly on unlock (snapshot the full inline `style` attribute and restore it verbatim, rather than adding/removing individual classes).
|
||||
- `filter: blur(2-3px)`, ~0.5 opacity, `cursor: not-allowed`, a non-blurred centered lock icon overlay, and `pointer-events: none` (or a transparent click-catcher div).
|
||||
- The container itself never gets `filter`/`opacity` directly — those are compositing properties that would recomposite (and blur/fade) the lock-icon overlay right along with it, since the overlay is appended as the container's own DOM child. Instead the overlay carries `backdrop-filter: blur(1px) grayscale(0.6) brightness(0.55)` plus a translucent `background`, which only affects what's painted *behind* the overlay — the container's real content — leaving the overlay's own children (the lock icon) crisp on top. `cursor: not-allowed` and `pointer-events: none` still go on the container; the overlay itself is the click-catcher (`pointer-events: auto`). See `blur-disable.ts`'s top-of-file comment for the full mechanism — don't reintroduce a `filter`/`opacity` on the container, it will blur the icon again.
|
||||
- The click-catcher must intercept `pointerdown`, `mousedown`, `mouseup`, *and* `click` (capture phase, `stopImmediatePropagation` on each) — fast trading UIs commonly execute the trade on `mousedown`/`pointerdown` rather than waiting for `click`, so intercepting `click` alone lets the action through before the catcher ever runs. Show the reason toast on `click` only, to avoid firing it 3-4x per gesture.
|
||||
- The reason toast auto-hides ~5s after being shown (resets the timer on each re-trigger, e.g. a repeated locked click); it has no manual close/dismiss control, but it is not meant to persist indefinitely.
|
||||
|
||||
@@ -184,5 +186,4 @@ Single source of truth for the wire protocol between the Osias backend (Rust/Axu
|
||||
|
||||
- No cost-basis tracking logic (backend concern).
|
||||
- No full-site-block implementation beyond a stub module.
|
||||
- ~~No threshold-setting UI (placeholder link only)~~ — implemented, see "Backend connection" above.
|
||||
- Only the axiom.trade adapter needs to be functional; the architecture just needs to make adding more sites trivial.
|
||||
|
||||
@@ -1,2 +1,42 @@
|
||||
# copilot
|
||||
Your blockchain powered Agent to help with your trading emotions
|
||||
# Osias — copilot
|
||||
|
||||
Your blockchain-powered trading companion. This is the open-source browser extension
|
||||
frontend (Apache License 2.0). See [`CLAUDE.md`](CLAUDE.md) for full architecture and
|
||||
protocol documentation.
|
||||
|
||||
## Build instructions
|
||||
|
||||
Built with [WXT](https://wxt.dev) (Vite-based). To reproduce the exact `.output/firefox-mv3`
|
||||
bundle submitted to addons.mozilla.org from source:
|
||||
|
||||
**Environment**
|
||||
- Node.js 22.x (built and tested on 22.11.0)
|
||||
- npm 11.x (built and tested on 11.14.1)
|
||||
|
||||
**Steps**
|
||||
```sh
|
||||
npm install # also runs `wxt prepare` via postinstall
|
||||
npm run build:firefox
|
||||
```
|
||||
|
||||
Output is written to `.output/firefox-mv3/`. To produce the uploaded `.zip` directly:
|
||||
|
||||
```sh
|
||||
npm run zip:firefox
|
||||
```
|
||||
|
||||
Output zip is written to `.output/`.
|
||||
|
||||
No build secrets, environment variables, or private dependencies are required — everything
|
||||
needed to build is in this repository and on the public npm registry, pinned via
|
||||
`package-lock.json`.
|
||||
|
||||
## Development
|
||||
|
||||
- `npm run dev:firefox` — dev build + watch targeting Firefox/Zen, auto-opens a temporary
|
||||
profile with the extension loaded.
|
||||
- `npm run compile` — `tsc --noEmit` type-check only.
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 — see [`LICENSE`](LICENSE).
|
||||
|
||||
@@ -32,7 +32,22 @@ interface WireError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
type WireMessage = WireLockState | WireAck | WireError;
|
||||
interface WirePong {
|
||||
type: 'pong';
|
||||
}
|
||||
|
||||
type WireMessage = WireLockState | WireAck | WireError | WirePong;
|
||||
|
||||
// JSON-level ping, per the protocol's "ping (optional fallback)" section —
|
||||
// used here specifically (not relying on the browser's native WS ping/pong
|
||||
// alone) because this client runs in a MV3 background service worker, which
|
||||
// Chromium can idle-kill after ~30s with no activity reaching its own
|
||||
// onMessage-style handlers; a plain protocol-level ping/pong frame isn't
|
||||
// guaranteed to count as that activity, but a JSON message our own
|
||||
// 'message' listener below processes is. Sending well under that 30s
|
||||
// budget (not waiting until the deadline) keeps the socket (and the
|
||||
// worker) alive continuously rather than trying to time a wakeup.
|
||||
const PING_INTERVAL_MS = 20_000;
|
||||
|
||||
const WIRE_TO_INTERNAL_SCOPE: Record<WireScope, LockScope> = {
|
||||
buy_only: 'buy-only',
|
||||
@@ -95,10 +110,14 @@ export function connectWsClient(options: WsClientOptions): WsClientController {
|
||||
const socket = new WebSocket(`${BACKEND_WS_URL}?token=${encodeURIComponent(token)}`);
|
||||
currentSocket = socket;
|
||||
let authFailure = false;
|
||||
let pingTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
reconnectAttempt = 0;
|
||||
options.onStatusChange('connected');
|
||||
pingTimer = setInterval(() => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'ping' }));
|
||||
}, PING_INTERVAL_MS);
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
@@ -129,9 +148,12 @@ export function connectWsClient(options: WsClientOptions): WsClientController {
|
||||
// 'ack' has nothing to react to yet — the client doesn't send
|
||||
// unlock_request/escalate_request until lock-decision logic exists
|
||||
// server-side (see backend/CLAUDE.md non-goals for this milestone).
|
||||
// 'pong' likewise needs no handling — sending the ping is the whole
|
||||
// point (see PING_INTERVAL_MS above), receiving a reply isn't acted on.
|
||||
});
|
||||
|
||||
socket.addEventListener('close', (event) => {
|
||||
if (pingTimer) clearInterval(pingTimer);
|
||||
if (currentSocket === socket) currentSocket = undefined;
|
||||
|
||||
if (manuallyDisconnected) {
|
||||
|
||||
@@ -17,22 +17,66 @@ const locked = new WeakMap<HTMLElement, LockedRecord>();
|
||||
// reason toast is surfaced on `click` once the gesture completes.
|
||||
const INTERCEPTED_EVENT_TYPES = ['pointerdown', 'mousedown', 'mouseup', 'click'] as const;
|
||||
|
||||
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 SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function createLockIcon(): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('width', '16');
|
||||
svg.setAttribute('height', '16');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
|
||||
const rect = document.createElementNS(SVG_NS, 'rect');
|
||||
rect.setAttribute('x', '4');
|
||||
rect.setAttribute('y', '10');
|
||||
rect.setAttribute('width', '16');
|
||||
rect.setAttribute('height', '10');
|
||||
rect.setAttribute('rx', '2');
|
||||
svg.appendChild(rect);
|
||||
|
||||
const path = document.createElementNS(SVG_NS, 'path');
|
||||
path.setAttribute('d', 'M8 10V7a4 4 0 0 1 8 0v3');
|
||||
svg.appendChild(path);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
// `backdrop-filter` (not `filter`) is deliberate: `filter` on the container
|
||||
// would recomposite the container's entire subtree — including the lock
|
||||
// icon overlay appended below as its child — as one filtered unit, blurring
|
||||
// the icon along with the button (filter/opacity are compositing
|
||||
// properties, so a child's own `filter:none` can't opt back out once an
|
||||
// ancestor sets one). `backdrop-filter` on the overlay instead only blurs
|
||||
// what's painted *behind* the overlay; the overlay's own children (the
|
||||
// icon) paint on top of that blurred backdrop unaffected, no DOM
|
||||
// restructuring of the site's own (React-owned) nodes required.
|
||||
const OVERLAY_STYLE = [
|
||||
'position:absolute',
|
||||
'inset:0',
|
||||
// 4px on every side instead of a flush `inset:0`, so the overlay reads as
|
||||
// slightly larger than the pill it's covering rather than exactly
|
||||
// clipped to it.
|
||||
'top:-4px',
|
||||
'bottom:-4px',
|
||||
'left:-4px',
|
||||
'right:-4px',
|
||||
'display:flex',
|
||||
'align-items:center',
|
||||
'justify-content:center',
|
||||
'pointer-events:auto',
|
||||
'cursor:not-allowed',
|
||||
'z-index:2147483000',
|
||||
'filter:none',
|
||||
'opacity:1',
|
||||
// A light blur (vs. the earlier 3px) avoids the pixelated look on small
|
||||
// pill-sized targets; darkening + desaturating the backdrop is what
|
||||
// actually communicates "disabled" here, the blur alone was carrying too
|
||||
// much of that job.
|
||||
'backdrop-filter:blur(1px) grayscale(0.6) brightness(0.55)',
|
||||
'-webkit-backdrop-filter:blur(1px) grayscale(0.6) brightness(0.55)',
|
||||
'background:rgba(0,0,0,0.25)',
|
||||
'color:#fff',
|
||||
'background:transparent',
|
||||
].join(';');
|
||||
|
||||
/**
|
||||
@@ -50,8 +94,6 @@ export const blurDisable: Intervention = {
|
||||
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') {
|
||||
@@ -63,7 +105,7 @@ export const blurDisable: Intervention = {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.dataset.osiasOverlay = 'true';
|
||||
overlay.style.cssText = OVERLAY_STYLE;
|
||||
overlay.innerHTML = LOCK_ICON_SVG;
|
||||
overlay.appendChild(createLockIcon());
|
||||
|
||||
const onOverlayEvent = (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -20,6 +20,9 @@ const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
|
||||
export function App() {
|
||||
const [lockState, setLockState] = useState<LockState>(DEFAULT_LOCK_STATE);
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(DEFAULT_CONNECTION_STATUS);
|
||||
const [settings, setSettings] = useState<ThresholdSettings | undefined>(undefined);
|
||||
const [settingsStatus, setSettingsStatus] = useState<'loading' | 'idle' | 'saving'>('loading');
|
||||
const [settingsError, setSettingsError] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
browser.runtime
|
||||
@@ -30,6 +33,17 @@ export function App() {
|
||||
.sendMessage({ type: 'osias:get-connection-status' } satisfies OsiasMessage)
|
||||
.then((status: ConnectionStatus | undefined) => setConnectionStatus(status ?? DEFAULT_CONNECTION_STATUS));
|
||||
|
||||
browser.runtime
|
||||
.sendMessage({ type: 'osias:get-settings' } satisfies OsiasMessage)
|
||||
.then((result: SettingsResult | undefined) => {
|
||||
setSettingsStatus('idle');
|
||||
if (result && 'settings' in result) {
|
||||
setSettings(result.settings);
|
||||
} else {
|
||||
setSettingsError(result?.error ?? 'Failed to load settings.');
|
||||
}
|
||||
});
|
||||
|
||||
const listener = (message: OsiasMessage) => {
|
||||
if (message?.type === 'osias:lock-state-changed') {
|
||||
setLockState(message.state);
|
||||
@@ -49,44 +63,80 @@ export function App() {
|
||||
void browser.runtime.sendMessage({ type: 'osias:switch-account' } satisfies OsiasMessage);
|
||||
}
|
||||
|
||||
function saveSettings(form: ThresholdForm): void {
|
||||
setSettingsStatus('saving');
|
||||
setSettingsError(undefined);
|
||||
browser.runtime
|
||||
.sendMessage({ type: 'osias:update-settings', settings: fromForm(form) } satisfies OsiasMessage)
|
||||
.then((result: SettingsResult | undefined) => {
|
||||
setSettingsStatus('idle');
|
||||
if (result && 'settings' in result) {
|
||||
setSettings(result.settings);
|
||||
} else {
|
||||
setSettingsError(result?.error ?? 'Failed to save settings.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="popup">
|
||||
<header>
|
||||
<h1>Osias</h1>
|
||||
<p className="subtitle">Trading companion</p>
|
||||
<div className="brand">
|
||||
<h1>Osias</h1>
|
||||
<p className="subtitle">Trading companion</p>
|
||||
</div>
|
||||
<div className="connection" title={CONNECTION_LABELS[connectionStatus]}>
|
||||
<span className={`connection-dot connection-${connectionStatus}`} />
|
||||
{CONNECTION_LABELS[connectionStatus]}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="connection">
|
||||
<span className={`connection-dot connection-${connectionStatus}`} />
|
||||
{' '}
|
||||
{CONNECTION_LABELS[connectionStatus]}
|
||||
</section>
|
||||
{settingsError && (
|
||||
<div className="error-banner" role="alert">
|
||||
<span className="error-icon" aria-hidden="true">
|
||||
⚠
|
||||
</span>
|
||||
<span className="error-text">{settingsError}</span>
|
||||
<button type="button" className="error-dismiss" aria-label="Dismiss" onClick={() => setSettingsError(undefined)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="status">
|
||||
<section className={`status-card ${lockState.locked ? 'status-card-locked' : 'status-card-unlocked'}`}>
|
||||
{lockState.locked ? (
|
||||
<>
|
||||
<div className="badge badge-locked">🔒 Locked</div>
|
||||
<p className="reason">{lockState.reason ?? 'No reason provided.'}</p>
|
||||
<p className="scope">Scope: {lockState.scope}</p>
|
||||
<div className="status-icon">🔒</div>
|
||||
<div className="status-body">
|
||||
<div className="badge badge-locked">Locked</div>
|
||||
<p className="reason">{lockState.reason ?? 'No reason provided.'}</p>
|
||||
<p className="scope">Scope: {lockState.scope}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="badge badge-unlocked">Unlocked</div>
|
||||
<>
|
||||
<div className="status-icon">✓</div>
|
||||
<div className="status-body">
|
||||
<div className="badge badge-unlocked">Unlocked</div>
|
||||
<p className="reason reason-muted">No active restrictions.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{connectionStatus === 'connected' && (
|
||||
<div className="account-actions">
|
||||
<button type="button" className="sign-out-button" onClick={switchAccount}>
|
||||
<button type="button" className="ghost-button" onClick={switchAccount}>
|
||||
Switch account
|
||||
</button>
|
||||
<button type="button" className="sign-out-button" onClick={signOut}>
|
||||
<button type="button" className="ghost-button" onClick={signOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ThresholdsPanel />
|
||||
</>
|
||||
<SettingsPanel settings={settings} status={settingsStatus} onSave={saveSettings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,60 +178,44 @@ function fromForm(form: ThresholdForm): Partial<ThresholdSettings> {
|
||||
};
|
||||
}
|
||||
|
||||
function ThresholdsPanel() {
|
||||
function SettingsPanel({
|
||||
settings,
|
||||
status,
|
||||
onSave,
|
||||
}: {
|
||||
settings: ThresholdSettings | undefined;
|
||||
status: 'loading' | 'idle' | 'saving';
|
||||
onSave: (form: ThresholdForm) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [form, setForm] = useState<ThresholdForm>(() => toForm(DEFAULT_THRESHOLD_SETTINGS));
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'saving'>('idle');
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [form, setForm] = useState<ThresholdForm>(() => toForm(settings ?? DEFAULT_THRESHOLD_SETTINGS));
|
||||
|
||||
function expand(): void {
|
||||
setExpanded(true);
|
||||
if (status !== 'idle') return;
|
||||
setStatus('loading');
|
||||
setError(undefined);
|
||||
browser.runtime
|
||||
.sendMessage({ type: 'osias:get-settings' } satisfies OsiasMessage)
|
||||
.then((result: SettingsResult | undefined) => {
|
||||
if (result && 'settings' in result) {
|
||||
setForm(toForm(result.settings));
|
||||
setStatus('idle');
|
||||
} else {
|
||||
setError(result?.error ?? 'Failed to load settings.');
|
||||
setStatus('idle');
|
||||
}
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
if (settings) setForm(toForm(settings));
|
||||
}, [settings]);
|
||||
|
||||
function field(key: keyof ThresholdForm, value: string): void {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
setStatus('saving');
|
||||
setError(undefined);
|
||||
browser.runtime
|
||||
.sendMessage({ type: 'osias:update-settings', settings: fromForm(form) } satisfies OsiasMessage)
|
||||
.then((result: SettingsResult | undefined) => {
|
||||
setStatus('idle');
|
||||
if (result && 'settings' in result) {
|
||||
setForm(toForm(result.settings));
|
||||
} else {
|
||||
setError(result?.error ?? 'Failed to save settings.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button type="button" className="thresholds-toggle" onClick={expand}>
|
||||
Adjust loss thresholds →
|
||||
<button type="button" className="settings-toggle" onClick={() => setExpanded(true)}>
|
||||
<span>Settings</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="thresholds-panel">
|
||||
<h2>Loss thresholds</h2>
|
||||
<section className="settings-panel">
|
||||
<div className="settings-panel-header">
|
||||
<h2>Settings</h2>
|
||||
<button type="button" className="settings-collapse" onClick={() => setExpanded(false)}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
<h3>Loss thresholds</h3>
|
||||
|
||||
<label>
|
||||
Exit counts as "full" at
|
||||
@@ -267,9 +301,12 @@ function ThresholdsPanel() {
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{error && <p className="thresholds-error">{error}</p>}
|
||||
|
||||
<button type="button" className="sign-out-button" onClick={save} disabled={status === 'saving'}>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => onSave(form)}
|
||||
disabled={status === 'saving' || !settings}
|
||||
>
|
||||
{status === 'saving' ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
+260
-77
@@ -1,142 +1,325 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #f6f7f8;
|
||||
--color-border: rgba(15, 23, 42, 0.1);
|
||||
--color-text: #0f172a;
|
||||
--color-muted: rgba(15, 23, 42, 0.6);
|
||||
--color-danger: #dc2626;
|
||||
--color-danger-bg: rgba(220, 38, 38, 0.1);
|
||||
--color-success: #16a34a;
|
||||
--color-success-bg: rgba(22, 163, 74, 0.1);
|
||||
--color-warning: #ca8a04;
|
||||
--color-accent: #0f172a;
|
||||
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
--font-size-md: 13px;
|
||||
--font-size-lg: 16px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #16181d;
|
||||
--color-surface: #1f222a;
|
||||
--color-border: rgba(255, 255, 255, 0.1);
|
||||
--color-text: #f1f5f9;
|
||||
--color-muted: rgba(241, 245, 249, 0.6);
|
||||
--color-danger: #f87171;
|
||||
--color-danger-bg: rgba(248, 113, 113, 0.14);
|
||||
--color-success: #4ade80;
|
||||
--color-success-bg: rgba(74, 222, 128, 0.14);
|
||||
--color-warning: #facc15;
|
||||
--color-accent: #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 280px;
|
||||
width: 320px;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font: 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font: var(--font-size-md)/1.45 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
.popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
header .brand h1 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
header .subtitle {
|
||||
margin: 2px 0 12px;
|
||||
opacity: 0.65;
|
||||
margin: 1px 0 0;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.connection {
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.75;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.connection-connecting {
|
||||
color: #ca8a04;
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.connection-connected {
|
||||
color: #16a34a;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.connection-disconnected,
|
||||
.connection-auth-error {
|
||||
color: #dc2626;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-bottom: 16px;
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-danger-bg);
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-dismiss {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.error-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-card-unlocked .status-icon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.badge-locked {
|
||||
background: rgba(220, 38, 38, 0.15);
|
||||
color: #dc2626;
|
||||
background: var(--color-danger-bg);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.badge-unlocked {
|
||||
background: rgba(22, 163, 74, 0.15);
|
||||
color: #16a34a;
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status .reason {
|
||||
margin: 8px 0 2px;
|
||||
.status-body .reason {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.status .scope {
|
||||
margin: 0;
|
||||
opacity: 0.65;
|
||||
.status-body .reason-muted {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.status-body .scope {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.account-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sign-out-button {
|
||||
.ghost-button,
|
||||
.primary-button {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.4);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ghost-button:hover {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
width: 100%;
|
||||
margin-top: var(--space-1);
|
||||
border: none;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: var(--space-2) 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: none;
|
||||
color: var(--color-muted);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-toggle:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.settings-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.settings-panel h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.settings-collapse {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-muted);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sign-out-button:hover {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
.settings-collapse:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.thresholds-toggle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
.settings-panel h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.thresholds-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.thresholds-panel {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.25);
|
||||
}
|
||||
|
||||
.thresholds-panel h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.thresholds-panel label {
|
||||
.settings-panel label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.threshold-input {
|
||||
@@ -144,21 +327,21 @@ header .subtitle {
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.threshold-input input {
|
||||
width: 48px;
|
||||
padding: 3px 5px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.4);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.thresholds-error {
|
||||
margin: 4px 0 8px;
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
.threshold-input input:focus {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -54,22 +54,35 @@ function ensureToast(): HTMLElement {
|
||||
|
||||
el = document.createElement('div');
|
||||
el.id = TOAST_ID;
|
||||
el.innerHTML = `
|
||||
<div class="osias-toast-icon">🔒</div>
|
||||
<div class="osias-toast-body">
|
||||
<div class="osias-toast-title">Osias: buying locked</div>
|
||||
<div class="osias-toast-reason"></div>
|
||||
<div class="osias-toast-action">View details</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'osias-toast-icon';
|
||||
icon.textContent = '🔒';
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'osias-toast-body';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'osias-toast-title';
|
||||
title.textContent = 'Osias: buying locked';
|
||||
|
||||
const reasonEl = document.createElement('div');
|
||||
reasonEl.className = 'osias-toast-reason';
|
||||
|
||||
const action = document.createElement('div');
|
||||
action.className = 'osias-toast-action';
|
||||
action.textContent = 'View details';
|
||||
// No manual close button — the toast only goes away on its own (auto-hide
|
||||
// timer below) or when the lock state actually changes; clicking it never
|
||||
// unlocks anything.
|
||||
el.querySelector('.osias-toast-action')?.addEventListener('click', () => {
|
||||
action.addEventListener('click', () => {
|
||||
const message: OsiasMessage = { type: 'osias:open-popup' };
|
||||
browser.runtime.sendMessage(message).catch(() => undefined);
|
||||
});
|
||||
|
||||
body.append(title, reasonEl, action);
|
||||
el.append(icon, body);
|
||||
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,17 @@ export default defineConfig({
|
||||
gecko: {
|
||||
// Placeholder id for local/dev builds; replace before publishing to AMO.
|
||||
id: '[email protected]',
|
||||
// Firefox-required data collection disclosure (Firefox 140+ / AMO
|
||||
// validation). Reflects what the extension actually transmits to the
|
||||
// backend: wallet address + signature during Phantom sign-in
|
||||
// (authenticationInfo, personallyIdentifyingInfo), and the wallet
|
||||
// identity that ties the account to on-chain trading activity
|
||||
// (financialAndPaymentInfo). See copilot/CLAUDE.md's "Backend
|
||||
// connection" and "Wallet auth" sections.
|
||||
data_collection_permissions: {
|
||||
required: ['authenticationInfo', 'personallyIdentifyingInfo', 'financialAndPaymentInfo'],
|
||||
optional: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user