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
This commit is contained in:
@@ -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
|
||||||
@@ -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/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.
|
||||||
- `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 below.
|
||||||
|
|
||||||
### Wallet auth (Phantom) — implemented
|
### Wallet auth (Phantom) — implemented
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
<h1>Osias</h1>
|
<div className="brand">
|
||||||
<p className="subtitle">Trading companion</p>
|
<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>
|
</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>
|
||||||
<p className="reason">{lockState.reason ?? 'No reason provided.'}</p>
|
<div className="status-body">
|
||||||
<p className="scope">Scope: {lockState.scope}</p>
|
<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>
|
</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
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user