Compare commits

...
11 Commits
Author SHA1 Message Date
selimaj-devandclaude cd81bec938 Write a proper project README with the brand mark
Replaces the minimal AMO-source-submission README with a full project
overview: logo, what Osias actually does, how it works, shipped
features, getting started, project structure, and contributing —
folding the existing build-instructions content in rather than
dropping it, since AMO's source review still depends on it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HoMzqFw3d93hG6c9xB4thU
2026-09-09 13:46:09 +02:00
selimaj-dev a9a7dc751b Merge pull request #8 from osias-trade/brand/pause-icon
Add the Osias brand mark (pause icon)
2026-09-09 07:35:30 -04:00
selimaj-devandclaude 1193684c29 Add 400x400 profile picture variants and ignore .DS_Store
Two flat-square backgrounds for the pause mark (#1E1912 and #17130F,
matching the site background exactly) sized for the GitHub org avatar
and similar profile contexts — not referenced by the extension
manifest.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HoMzqFw3d93hG6c9xB4thU
2026-09-09 13:34:08 +02:00
selimaj-devandclaude 9ef964892f Add the Osias icon — a pause mark, browser toolbar and store icon
Two uneven vertical bars: reads as a paused instrument first, with a
quieter second reading as a frozen price candle. Chosen over a padlock
mark (the most generic symbol in crypto security) and a wordmark-only
option (still needs a fallback mark for toolbar/favicon contexts) —
see the logo exploration canvas for the full comparison.

WXT auto-discovers public/icon/{16,32,48,96,128}.png by filename
convention and populates manifest.icons at build time — no
wxt.config.ts change needed. osias-icon-512.png and the standalone
SVG are kept alongside for general branding use, not part of the
manifest.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HoMzqFw3d93hG6c9xB4thU
2026-09-09 13:27:08 +02:00
selimaj-devandclaude 891d561aaa Fix AMO submission requirements: data collection permissions, innerHTML lint, source README
- Declare data_collection_permissions in browser_specific_settings.gecko
  (Firefox 140+/AMO validation requirement) to reflect wallet address,
  auth signature, and trading-identity data sent to the backend.
- Replace innerHTML assignments in blur-disable.ts and toast.ts with
  DOM construction (createElementNS/createElement) to clear the AMO
  linter's unsafe-innerHTML warning on content.js.
- Add build instructions to README.md for AMO's source code submission
  requirement, triggered by WXT/Vite's bundling and minification.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HoMzqFw3d93hG6c9xB4thU
2026-09-09 12:20:31 +02:00
selimaj-devandclaude cd56cf60cd Clean up CLAUDE.md: cut changelog narrative, fix stale sections
Removed decorative status narration ("used to have a mock toggle, removed
once...") and strikethrough'd resolved non-goals in favor of describing
current state directly. Kept the genuine postmortems intact (Firefox
CustomEvent Xray bug, accountChanged double-fire bug, buy/sell wrapper
gotcha) since those actively prevent regressions.

Also fixed the "Visual treatment for v1" section, which still described
the old filter: blur() approach — now documents the backdrop-filter fix
from earlier this session and explicitly warns against reintroducing
filter/opacity on the container. Documented the ping/pong keepalive in
both the wire-protocol spec and "Backend connection".

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013qK4zsF5ogU7cjRVL3AQZ2
2026-09-09 03:26:17 +02:00
selimaj-dev abdc0c9fca Merge pull request #7 from osias-trade/fix/blur-icon-and-ws-keepalive
Fix blurred lock icon overlay; add WS ping keepalive
2026-09-08 21:09:15 -04:00
selimaj-devandclaude c87f86f82e Send a ping every 20s to keep the WS connection (and MV3 worker) alive
Backend now replies with {"type":"pong"}. See backend commit for the full
rationale: a MV3 background service worker can be idle-killed after ~30s
with no activity reaching its own message handlers, and a disconnect while
a lockout is active used to reset the visible lock state on reconnect
(fixed backend-side) — this keepalive addresses the other half by making
that disconnect much less likely to happen in the first place.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013qK4zsF5ogU7cjRVL3AQZ2
2026-09-09 03:03:51 +02:00
selimaj-devandclaude 1222baadee Fix lock icon getting blurred with its container, tune overlay look
filter/opacity on the container recomposited its whole subtree
(including the lock icon appended as a child) as one filtered unit, so
the icon's own filter:none/opacity:1 couldn't opt it back out. Switch
to backdrop-filter on the overlay instead, which only blurs what's
painted behind it, leaving the icon crisp with no need to reparent the
site's own React-owned DOM.

Also lighten the blur (pixelated on small pills) in favor of
grayscale/brightness doing the "disabled" signaling, and size the
overlay 4px larger on every side than a flush inset:0.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013qK4zsF5ogU7cjRVL3AQZ2
2026-09-09 02:30:44 +02:00
selimaj-dev 705945bf25 Merge pull request #6 from osias-trade/popup/redesign-and-settings-errors
Redesign popup UI, surface settings errors on main screen
2026-09-08 07:22:02 -04:00
selimaj-devandclaude 9a774a38d4 Redesign popup UI, rename thresholds panel to Settings, surface errors on main screen
UX changes:
- Settings load/save errors now surface as a dismissible banner on the
  main popup screen, not only inside the collapsed panel. Settings are
  fetched eagerly (like lock state/connection status already were)
  instead of lazily on first expand, so a load failure is visible
  immediately.
- The "Adjust loss thresholds" toggle/panel is renamed to "Settings"
  (with "Loss thresholds" as a subsection heading, since that's the
  only setting today), and gains a "Done" affordance to collapse it
  back rather than only being expandable.
- Lock status is now the visual hero (a bordered status card with an
  icon), rather than one item in a flat list alongside connection
  status and account actions.

Visual changes:
- style.css rewritten around CSS custom-property design tokens
  (spacing/radius/font-size scale, semantic colors) with explicit
  light and dark palettes, instead of hardcoded hex colors reused
  as-is across both color schemes.
- Popup widened 280px -> 320px for breathing room; card-based grouping
  (status, settings) replaces the previous flat stack of sections.

No wire-protocol or messaging-contract changes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tn7uDYjTuZEbLPwpEiSCUw
2026-09-08 13:20:05 +02:00
18 changed files with 615 additions and 173 deletions
+3
View File
@@ -1,3 +1,6 @@
# macOS
.DS_Store
# Logs # Logs
logs logs
*.log *.log
+16 -15
View File
@@ -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. 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 ## Target platform
@@ -62,7 +62,7 @@ Planned structure:
intervention-interface.ts intervention-interface.ts
observer.ts - generic MutationObserver watching for adapter-declared selectors (SPA-safe) observer.ts - generic MutationObserver watching for adapter-declared selectors (SPA-safe)
content-index.ts - wires adapter + intervention + background messages together 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 /notifications - toast/banner injected into page or via browser notification API
/shared /shared
messaging.ts - typed message contracts between background <-> content scripts <-> popup 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 ## 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. 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. - `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/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/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/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. 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-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. - `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. - **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. - "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. - "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. - `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 (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. - **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. - **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) ## 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`. Client should close and re-auth on `auth_expired`.
- `pong` — reply to a client `ping` (below): `{ "type": "pong" }`.
**Client → server messages** **Client → server messages**
- `unlock_request` — user explicitly chose to unlock: - `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"`. `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** **Connection lifecycle**
1. Client opens `wss://.../ws?token=...`. 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`) ## 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). - 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 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. - 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 cost-basis tracking logic (backend concern).
- No full-site-block implementation beyond a stub module. - 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. - Only the axiom.trade adapter needs to be functional; the architecture just needs to make adding more sites trivial.
+128 -2
View File
@@ -1,2 +1,128 @@
# copilot <p align="center">
Your blockchain powered Agent to help with your trading emotions <img src="public/icon/osias-icon-512.png" width="96" height="96" alt="Osias" />
</p>
<h1 align="center">Osias</h1>
<p align="center">
A blockchain-powered trading companion for the emotional side of trading.
</p>
<p align="center">
<a href="https://osias.trade">Website</a> ·
<a href="https://osias.trade/privacy">Privacy Policy</a> ·
<a href="LICENSE">Apache 2.0 License</a>
</p>
---
## About
Osias isn't a trading bot or a P&L tracker. Trades are already visible on-chain, so
instead of asking you to log anything, Osias watches your connected Solana wallet in
real time and steps in only when it detects loss-driven trading — a single bad trade or
a losing streak past a threshold you control — by locking new buy actions on supported
trading sites until the moment passes.
This repository is the **open-source browser extension frontend** (Apache 2.0). The
backend that makes the actual lock/unlock decisions — auth, wallet/RPC listening,
realized-PnL tracking — is a separate, private service; this extension talks to it over
a documented WebSocket protocol and never contains trading logic of its own.
## How it works
1. **Connect your wallet** — real Phantom sign-in via message signing. Your seed phrase
and private key never touch the extension.
2. **Osias watches** — a live Solana subscription tracks your realized P&L in the
background while you're connected, nothing more.
3. **Buying locks** — cross your configured threshold and new buy actions visually
disable on the page. Sell and manage actions are never touched.
4. **The moment passes** — the lock auto-unlocks after a configurable cooldown, or you
can unlock manually with an explicit confirmation.
## Features
- Real Phantom wallet authentication — connect, sign, sign out, switch accounts
- Real-time lockout enforcement on [axiom.trade](https://axiom.trade), with a
non-destructive blur + lock-icon visual treatment
- Two configurable circuit breakers (single-trade and losing-streak loss thresholds)
plus a configurable auto-unlock cooldown, all tunable from the extension popup
- Live backend connection status, so a dropped connection is never silently mistaken
for "unlocked"
- Built for Firefox/Zen and Chromium (Manifest V3) from one codebase
See [`CLAUDE.md`](CLAUDE.md) for the full architecture — including the site-adapter /
intervention-strategy split that new sites and lock treatments build on, and the
complete WebSocket wire protocol shared with the backend.
## Getting started
Scaffolded with [WXT](https://wxt.dev) (Vite-based, TypeScript, React,
`webextension-polyfill`). Package manager: npm.
```sh
npm install # also runs `wxt prepare` via postinstall
npm run dev:firefox # dev build + watch, auto-opens a temporary Firefox/Zen profile
```
Other commands:
| Command | What it does |
| ----------------------- | ---------------------------------------------------------- |
| `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:firefox` | Production build → `.output/firefox-mv3/` |
| `npm run zip:firefox` | Production build packaged as a `.zip` for store upload |
| `npm run compile` | `tsc --noEmit` type-check only |
To load an unpacked build: in Firefox/Zen, go to `about:debugging#/runtime/this-firefox`
→ "Load Temporary Add-on" → select any file inside `.output/firefox-mv3/`.
### Building from source for a store submission
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`.
- Node.js 22.x (built and tested on 22.11.0)
- npm 11.x (built and tested on 11.14.1)
```sh
npm install
npm run build:firefox # → .output/firefox-mv3/
npm run zip:firefox # → .output/*.zip
```
## Project structure
```
/src
/background WebSocket connection to the backend, lock state, messaging
/content-scripts
/adapters Site-specific selectors + DOM strategy (axiom.trade first)
/interventions Visual lock treatments (blur-disable, full-block stub)
/popup Status, lock reason, Settings panel
/notifications Toast/banner injected into the page
/shared Typed message contracts, config
```
Decision-making (backend), site detection, and enforcement are deliberately decoupled —
adding a new supported site or a new intervention type never requires touching the
other two. Full detail in [`CLAUDE.md`](CLAUDE.md).
## Status
Early public beta. Working end-to-end: real Phantom wallet sign-in, the `/ws`
connection with reconnect handling, real-time lockout on axiom.trade, and
per-user-configurable thresholds. See [`CLAUDE.md`](CLAUDE.md#current-status) for the
full current-state summary.
## Contributing
Issues and pull requests are welcome — this is a young project and honest feedback
(bug reports especially) is genuinely useful right now. Please open an issue at
[github.com/osias-trade/copilot/issues](https://github.com/osias-trade/copilot/issues).
## License
[Apache License 2.0](LICENSE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="18" y="15" width="10" height="34" rx="5" fill="#e2824c"/>
<rect x="36" y="19" width="10" height="26" rx="5" fill="#e2824c"/>
</svg>

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+23 -1
View File
@@ -32,7 +32,22 @@ interface WireError {
message: string; 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> = { const WIRE_TO_INTERNAL_SCOPE: Record<WireScope, LockScope> = {
buy_only: 'buy-only', buy_only: 'buy-only',
@@ -95,10 +110,14 @@ export function connectWsClient(options: WsClientOptions): WsClientController {
const socket = new WebSocket(`${BACKEND_WS_URL}?token=${encodeURIComponent(token)}`); const socket = new WebSocket(`${BACKEND_WS_URL}?token=${encodeURIComponent(token)}`);
currentSocket = socket; currentSocket = socket;
let authFailure = false; let authFailure = false;
let pingTimer: ReturnType<typeof setInterval> | undefined;
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
reconnectAttempt = 0; reconnectAttempt = 0;
options.onStatusChange('connected'); options.onStatusChange('connected');
pingTimer = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'ping' }));
}, PING_INTERVAL_MS);
}); });
socket.addEventListener('message', (event) => { 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 // 'ack' has nothing to react to yet — the client doesn't send
// unlock_request/escalate_request until lock-decision logic exists // unlock_request/escalate_request until lock-decision logic exists
// server-side (see backend/CLAUDE.md non-goals for this milestone). // 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) => { socket.addEventListener('close', (event) => {
if (pingTimer) clearInterval(pingTimer);
if (currentSocket === socket) currentSocket = undefined; if (currentSocket === socket) currentSocket = undefined;
if (manuallyDisconnected) { if (manuallyDisconnected) {
@@ -17,22 +17,66 @@ const locked = new WeakMap<HTMLElement, LockedRecord>();
// reason toast is surfaced on `click` once the gesture completes. // reason toast is surfaced on `click` once the gesture completes.
const INTERCEPTED_EVENT_TYPES = ['pointerdown', 'mousedown', 'mouseup', 'click'] as const; const INTERCEPTED_EVENT_TYPES = ['pointerdown', 'mousedown', 'mouseup', 'click'] as const;
const LOCK_ICON_SVG = const SVG_NS = 'http://www.w3.org/2000/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>';
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 = [ const OVERLAY_STYLE = [
'position:absolute', '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', 'display:flex',
'align-items:center', 'align-items:center',
'justify-content:center', 'justify-content:center',
'pointer-events:auto', 'pointer-events:auto',
'cursor:not-allowed', 'cursor:not-allowed',
'z-index:2147483000', 'z-index:2147483000',
'filter:none', // A light blur (vs. the earlier 3px) avoids the pixelated look on small
'opacity:1', // 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', 'color:#fff',
'background:transparent',
].join(';'); ].join(';');
/** /**
@@ -50,8 +94,6 @@ export const blurDisable: Intervention = {
const originalStyleAttr = container.getAttribute('style'); const originalStyleAttr = container.getAttribute('style');
const computedPosition = getComputedStyle(container).position; const computedPosition = getComputedStyle(container).position;
container.style.filter = 'blur(3px)';
container.style.opacity = '0.5';
container.style.cursor = 'not-allowed'; container.style.cursor = 'not-allowed';
container.style.pointerEvents = 'none'; container.style.pointerEvents = 'none';
if (computedPosition === 'static') { if (computedPosition === 'static') {
@@ -63,7 +105,7 @@ export const blurDisable: Intervention = {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.dataset.osiasOverlay = 'true'; overlay.dataset.osiasOverlay = 'true';
overlay.style.cssText = OVERLAY_STYLE; overlay.style.cssText = OVERLAY_STYLE;
overlay.innerHTML = LOCK_ICON_SVG; overlay.appendChild(createLockIcon());
const onOverlayEvent = (event: Event) => { const onOverlayEvent = (event: Event) => {
event.preventDefault(); event.preventDefault();
+92 -55
View File
@@ -20,6 +20,9 @@ const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
export function App() { export function App() {
const [lockState, setLockState] = useState<LockState>(DEFAULT_LOCK_STATE); const [lockState, setLockState] = useState<LockState>(DEFAULT_LOCK_STATE);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(DEFAULT_CONNECTION_STATUS); 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(() => { useEffect(() => {
browser.runtime browser.runtime
@@ -30,6 +33,17 @@ export function App() {
.sendMessage({ type: 'osias:get-connection-status' } satisfies OsiasMessage) .sendMessage({ type: 'osias:get-connection-status' } satisfies OsiasMessage)
.then((status: ConnectionStatus | undefined) => setConnectionStatus(status ?? DEFAULT_CONNECTION_STATUS)); .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) => { const listener = (message: OsiasMessage) => {
if (message?.type === 'osias:lock-state-changed') { if (message?.type === 'osias:lock-state-changed') {
setLockState(message.state); setLockState(message.state);
@@ -49,44 +63,80 @@ export function App() {
void browser.runtime.sendMessage({ type: 'osias:switch-account' } satisfies OsiasMessage); 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 ( return (
<> <div className="popup">
<header> <header>
<div className="brand">
<h1>Osias</h1> <h1>Osias</h1>
<p className="subtitle">Trading companion</p> <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> </header>
<section className="connection"> {settingsError && (
<span className={`connection-dot connection-${connectionStatus}`} /> <div className="error-banner" role="alert">
{' '} <span className="error-icon" aria-hidden="true">
{CONNECTION_LABELS[connectionStatus]} ⚠
</section> </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 ? ( {lockState.locked ? (
<> <>
<div className="badge badge-locked">🔒 Locked</div> <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="reason">{lockState.reason ?? 'No reason provided.'}</p>
<p className="scope">Scope: {lockState.scope}</p> <p className="scope">Scope: {lockState.scope}</p>
</div>
</> </>
) : ( ) : (
<>
<div className="status-icon">✓</div>
<div className="status-body">
<div className="badge badge-unlocked">Unlocked</div> <div className="badge badge-unlocked">Unlocked</div>
<p className="reason reason-muted">No active restrictions.</p>
</div>
</>
)} )}
</section> </section>
{connectionStatus === 'connected' && ( {connectionStatus === 'connected' && (
<div className="account-actions"> <div className="account-actions">
<button type="button" className="sign-out-button" onClick={switchAccount}> <button type="button" className="ghost-button" onClick={switchAccount}>
Switch account Switch account
</button> </button>
<button type="button" className="sign-out-button" onClick={signOut}> <button type="button" className="ghost-button" onClick={signOut}>
Sign out Sign out
</button> </button>
</div> </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 [expanded, setExpanded] = useState(false);
const [form, setForm] = useState<ThresholdForm>(() => toForm(DEFAULT_THRESHOLD_SETTINGS)); const [form, setForm] = useState<ThresholdForm>(() => toForm(settings ?? DEFAULT_THRESHOLD_SETTINGS));
const [status, setStatus] = useState<'idle' | 'loading' | 'saving'>('idle');
const [error, setError] = useState<string | undefined>(undefined);
function expand(): void { useEffect(() => {
setExpanded(true); if (settings) setForm(toForm(settings));
if (status !== 'idle') return; }, [settings]);
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');
}
});
}
function field(key: keyof ThresholdForm, value: string): void { function field(key: keyof ThresholdForm, value: string): void {
setForm((prev) => ({ ...prev, [key]: value })); 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) { if (!expanded) {
return ( return (
<button type="button" className="thresholds-toggle" onClick={expand}> <button type="button" className="settings-toggle" onClick={() => setExpanded(true)}>
Adjust loss thresholds → <span>Settings</span>
<span aria-hidden="true">→</span>
</button> </button>
); );
} }
return ( return (
<section className="thresholds-panel"> <section className="settings-panel">
<h2>Loss thresholds</h2> <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> <label>
Exit counts as "full" at Exit counts as "full" at
@@ -267,9 +301,12 @@ function ThresholdsPanel() {
</div> </div>
</label> </label>
{error && <p className="thresholds-error">{error}</p>} <button
type="button"
<button type="button" className="sign-out-button" onClick={save} disabled={status === 'saving'}> className="primary-button"
onClick={() => onSave(form)}
disabled={status === 'saving' || !settings}
>
{status === 'saving' ? 'Saving…' : 'Save'} {status === 'saving' ? 'Saving…' : 'Save'}
</button> </button>
</section> </section>
+260 -77
View File
@@ -1,142 +1,325 @@
:root { :root {
color-scheme: light dark; 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 { body {
width: 280px; width: 320px;
margin: 0; margin: 0;
padding: 16px; background: var(--color-bg);
font: 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 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; margin: 0;
font-size: 16px; font-size: var(--font-size-lg);
font-weight: 700;
letter-spacing: -0.01em;
} }
header .subtitle { header .subtitle {
margin: 2px 0 12px; margin: 1px 0 0;
opacity: 0.65; color: var(--color-muted);
font-size: var(--font-size-xs);
} }
.connection { .connection {
margin-bottom: 10px;
opacity: 0.75;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin-top: 2px;
color: var(--color-muted);
font-size: var(--font-size-xs);
white-space: nowrap;
} }
.connection-dot { .connection-dot {
width: 8px; width: 7px;
height: 8px; height: 7px;
border-radius: 50%; border-radius: 50%;
display: inline-block; flex-shrink: 0;
background: currentColor; background: currentColor;
} }
.connection-connecting { .connection-connecting {
color: #ca8a04; color: var(--color-warning);
} }
.connection-connected { .connection-connected {
color: #16a34a; color: var(--color-success);
} }
.connection-disconnected, .connection-disconnected,
.connection-auth-error { .connection-auth-error {
color: #dc2626; color: var(--color-danger);
} }
.status { .error-banner {
margin-bottom: 16px; 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 { .badge {
display: inline-block; display: inline-block;
padding: 3px 8px; padding: 2px 9px;
border-radius: 999px; border-radius: 999px;
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.02em;
} }
.badge-locked { .badge-locked {
background: rgba(220, 38, 38, 0.15); background: var(--color-danger-bg);
color: #dc2626; color: var(--color-danger);
} }
.badge-unlocked { .badge-unlocked {
background: rgba(22, 163, 74, 0.15); background: var(--color-success-bg);
color: #16a34a; color: var(--color-success);
} }
.status .reason { .status-body .reason {
margin: 8px 0 2px; margin: var(--space-2) 0 0;
font-size: var(--font-size-md);
} }
.status .scope { .status-body .reason-muted {
margin: 0; color: var(--color-muted);
opacity: 0.65; }
.status-body .scope {
margin: var(--space-1) 0 0;
color: var(--color-muted);
font-size: var(--font-size-xs);
} }
.account-actions { .account-actions {
display: flex; display: flex;
gap: 8px; gap: var(--space-2);
} }
.sign-out-button { .ghost-button,
.primary-button {
flex: 1; flex: 1;
padding: 6px 10px; padding: 7px 10px;
border: 1px solid rgba(128, 128, 128, 0.4); border-radius: var(--radius-sm);
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit; font: inherit;
font-weight: 600; 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; cursor: pointer;
} }
.sign-out-button:hover { .settings-collapse:hover {
background: rgba(128, 128, 128, 0.12); color: var(--color-text);
} }
.thresholds-toggle { .settings-panel h3 {
display: block; margin: 0 0 var(--space-2);
width: 100%; font-size: var(--font-size-xs);
margin-top: 14px; font-weight: 600;
padding: 0; color: var(--color-muted);
border: none; text-transform: uppercase;
background: none; letter-spacing: 0.03em;
color: inherit;
opacity: 0.75;
font: inherit;
text-align: left;
cursor: pointer;
} }
.thresholds-toggle:hover { .settings-panel label {
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 {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: var(--space-2);
margin-bottom: 8px; margin-bottom: var(--space-2);
font-size: 12px; font-size: var(--font-size-sm);
opacity: 0.85; color: var(--color-muted);
} }
.threshold-input { .threshold-input {
@@ -144,21 +327,21 @@ header .subtitle {
align-items: center; align-items: center;
gap: 4px; gap: 4px;
flex-shrink: 0; flex-shrink: 0;
color: var(--color-text);
} }
.threshold-input input { .threshold-input input {
width: 48px; width: 48px;
padding: 3px 5px; padding: 4px 6px;
border: 1px solid rgba(128, 128, 128, 0.4); border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
background: transparent; background: var(--color-surface);
color: inherit; color: inherit;
font: inherit; font: inherit;
text-align: right; text-align: right;
} }
.thresholds-error { .threshold-input input:focus {
margin: 4px 0 8px; outline: 2px solid var(--color-accent);
color: #dc2626; outline-offset: 1px;
font-size: 12px;
} }
+22 -9
View File
@@ -54,22 +54,35 @@ function ensureToast(): HTMLElement {
el = document.createElement('div'); el = document.createElement('div');
el.id = TOAST_ID; el.id = TOAST_ID;
el.innerHTML = `
<div class="osias-toast-icon">🔒</div> const icon = document.createElement('div');
<div class="osias-toast-body"> icon.className = 'osias-toast-icon';
<div class="osias-toast-title">Osias: buying locked</div> icon.textContent = '🔒';
<div class="osias-toast-reason"></div>
<div class="osias-toast-action">View details</div> const body = document.createElement('div');
</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 // 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 // timer below) or when the lock state actually changes; clicking it never
// unlocks anything. // unlocks anything.
el.querySelector('.osias-toast-action')?.addEventListener('click', () => { action.addEventListener('click', () => {
const message: OsiasMessage = { type: 'osias:open-popup' }; const message: OsiasMessage = { type: 'osias:open-popup' };
browser.runtime.sendMessage(message).catch(() => undefined); browser.runtime.sendMessage(message).catch(() => undefined);
}); });
body.append(title, reasonEl, action);
el.append(icon, body);
document.body.appendChild(el); document.body.appendChild(el);
return el; return el;
} }
+11
View File
@@ -17,6 +17,17 @@ export default defineConfig({
gecko: { gecko: {
// Placeholder id for local/dev builds; replace before publishing to AMO. // Placeholder id for local/dev builds; replace before publishing to AMO.
id: '[email protected]', 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: [],
},
}, },
}, },
}, },