From 9a774a38d40ca36430f08f748d026d8ce7aa237e Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Tue, 8 Sep 2026 13:20:05 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Tn7uDYjTuZEbLPwpEiSCUw --- CLAUDE.md | 4 +- src/entrypoints/popup/App.tsx | 157 +++++++++------ src/entrypoints/popup/style.css | 337 ++++++++++++++++++++++++-------- 3 files changed, 359 insertions(+), 139 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2a088f5..0164875 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -93,7 +93,7 @@ Note the naming mismatch with the wire protocol below: the frontend's internal ` - `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/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 below. ### Wallet auth (Phantom) — implemented diff --git a/src/entrypoints/popup/App.tsx b/src/entrypoints/popup/App.tsx index f34cf23..a1ce7f2 100644 --- a/src/entrypoints/popup/App.tsx +++ b/src/entrypoints/popup/App.tsx @@ -20,6 +20,9 @@ const CONNECTION_LABELS: Record = { export function App() { const [lockState, setLockState] = useState(DEFAULT_LOCK_STATE); const [connectionStatus, setConnectionStatus] = useState(DEFAULT_CONNECTION_STATUS); + const [settings, setSettings] = useState(undefined); + const [settingsStatus, setSettingsStatus] = useState<'loading' | 'idle' | 'saving'>('loading'); + const [settingsError, setSettingsError] = useState(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 ( - <> +
-

Osias

-

Trading companion

+
+

Osias

+

Trading companion

+
+
+ + {CONNECTION_LABELS[connectionStatus]} +
-
- - {' '} - {CONNECTION_LABELS[connectionStatus]} -
+ {settingsError && ( +
+ + {settingsError} + +
+ )} -
+
{lockState.locked ? ( <> -
🔒 Locked
-

{lockState.reason ?? 'No reason provided.'}

-

Scope: {lockState.scope}

+
🔒
+
+
Locked
+

{lockState.reason ?? 'No reason provided.'}

+

Scope: {lockState.scope}

+
) : ( -
Unlocked
+ <> +
✓
+
+
Unlocked
+

No active restrictions.

+
+ )}
{connectionStatus === 'connected' && (
- -
)} - - + +
); } @@ -128,60 +178,44 @@ function fromForm(form: ThresholdForm): Partial { }; } -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(() => toForm(DEFAULT_THRESHOLD_SETTINGS)); - const [status, setStatus] = useState<'idle' | 'loading' | 'saving'>('idle'); - const [error, setError] = useState(undefined); + const [form, setForm] = useState(() => 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 ( - ); } return ( -
-

Loss thresholds

+
+
+

Settings

+ +
+

Loss thresholds

- {error &&

{error}

} - -
diff --git a/src/entrypoints/popup/style.css b/src/entrypoints/popup/style.css index d7dba78..c596632 100644 --- a/src/entrypoints/popup/style.css +++ b/src/entrypoints/popup/style.css @@ -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; }