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
315 lines
9.8 KiB
TypeScript
315 lines
9.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { browser } from 'wxt/browser';
|
||
import {
|
||
DEFAULT_CONNECTION_STATUS,
|
||
DEFAULT_LOCK_STATE,
|
||
DEFAULT_THRESHOLD_SETTINGS,
|
||
type ConnectionStatus,
|
||
type LockState,
|
||
type ThresholdSettings,
|
||
} from '@/shared/types';
|
||
import type { OsiasMessage, SettingsResult } from '@/shared/messaging';
|
||
|
||
const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
|
||
connecting: 'Connecting…',
|
||
connected: 'Connected',
|
||
disconnected: 'Disconnected — retrying',
|
||
'auth-error': 'Sign-in failed — retrying',
|
||
};
|
||
|
||
export function App() {
|
||
const [lockState, setLockState] = useState<LockState>(DEFAULT_LOCK_STATE);
|
||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(DEFAULT_CONNECTION_STATUS);
|
||
const [settings, setSettings] = useState<ThresholdSettings | undefined>(undefined);
|
||
const [settingsStatus, setSettingsStatus] = useState<'loading' | 'idle' | 'saving'>('loading');
|
||
const [settingsError, setSettingsError] = useState<string | undefined>(undefined);
|
||
|
||
useEffect(() => {
|
||
browser.runtime
|
||
.sendMessage({ type: 'osias:get-lock-state' } satisfies OsiasMessage)
|
||
.then((state: LockState | undefined) => setLockState(state ?? DEFAULT_LOCK_STATE));
|
||
|
||
browser.runtime
|
||
.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);
|
||
} else if (message?.type === 'osias:connection-status-changed') {
|
||
setConnectionStatus(message.status);
|
||
}
|
||
};
|
||
browser.runtime.onMessage.addListener(listener);
|
||
return () => browser.runtime.onMessage.removeListener(listener);
|
||
}, []);
|
||
|
||
function signOut(): void {
|
||
void browser.runtime.sendMessage({ type: 'osias:sign-out' } satisfies OsiasMessage);
|
||
}
|
||
|
||
function switchAccount(): void {
|
||
void browser.runtime.sendMessage({ type: 'osias:switch-account' } satisfies OsiasMessage);
|
||
}
|
||
|
||
function saveSettings(form: ThresholdForm): void {
|
||
setSettingsStatus('saving');
|
||
setSettingsError(undefined);
|
||
browser.runtime
|
||
.sendMessage({ type: 'osias:update-settings', settings: fromForm(form) } satisfies OsiasMessage)
|
||
.then((result: SettingsResult | undefined) => {
|
||
setSettingsStatus('idle');
|
||
if (result && 'settings' in result) {
|
||
setSettings(result.settings);
|
||
} else {
|
||
setSettingsError(result?.error ?? 'Failed to save settings.');
|
||
}
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className="popup">
|
||
<header>
|
||
<div className="brand">
|
||
<h1>Osias</h1>
|
||
<p className="subtitle">Trading companion</p>
|
||
</div>
|
||
<div className="connection" title={CONNECTION_LABELS[connectionStatus]}>
|
||
<span className={`connection-dot connection-${connectionStatus}`} />
|
||
{CONNECTION_LABELS[connectionStatus]}
|
||
</div>
|
||
</header>
|
||
|
||
{settingsError && (
|
||
<div className="error-banner" role="alert">
|
||
<span className="error-icon" aria-hidden="true">
|
||
⚠
|
||
</span>
|
||
<span className="error-text">{settingsError}</span>
|
||
<button type="button" className="error-dismiss" aria-label="Dismiss" onClick={() => setSettingsError(undefined)}>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<section className={`status-card ${lockState.locked ? 'status-card-locked' : 'status-card-unlocked'}`}>
|
||
{lockState.locked ? (
|
||
<>
|
||
<div className="status-icon">🔒</div>
|
||
<div className="status-body">
|
||
<div className="badge badge-locked">Locked</div>
|
||
<p className="reason">{lockState.reason ?? 'No reason provided.'}</p>
|
||
<p className="scope">Scope: {lockState.scope}</p>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="status-icon">✓</div>
|
||
<div className="status-body">
|
||
<div className="badge badge-unlocked">Unlocked</div>
|
||
<p className="reason reason-muted">No active restrictions.</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
|
||
{connectionStatus === 'connected' && (
|
||
<div className="account-actions">
|
||
<button type="button" className="ghost-button" onClick={switchAccount}>
|
||
Switch account
|
||
</button>
|
||
<button type="button" className="ghost-button" onClick={signOut}>
|
||
Sign out
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<SettingsPanel settings={settings} status={settingsStatus} onSave={saveSettings} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Percent-shaped fields (`near_full_exit_fraction`, both loss thresholds) are
|
||
* edited here as whole numbers (e.g. "30") and converted to/from the
|
||
* backend's fractional wire representation only at this boundary — same
|
||
* "convert only at the seam" rule already applied to LockScope's kebab/snake
|
||
* case split (see ../../CLAUDE.md).
|
||
*/
|
||
type ThresholdForm = {
|
||
exitPercent: string;
|
||
windowHours: string;
|
||
streakLength: string;
|
||
singleLossPercent: string;
|
||
streakLossPercent: string;
|
||
cooldownMinutes: string;
|
||
};
|
||
|
||
function toForm(settings: ThresholdSettings): ThresholdForm {
|
||
return {
|
||
exitPercent: String(Math.round(settings.near_full_exit_fraction * 100)),
|
||
windowHours: String(settings.loss_window_hours),
|
||
streakLength: String(settings.streak_length),
|
||
singleLossPercent: String(Math.round(-settings.single_trade_loss_threshold * 100)),
|
||
streakLossPercent: String(Math.round(-settings.streak_sum_loss_threshold * 100)),
|
||
cooldownMinutes: String(settings.cooldown_minutes),
|
||
};
|
||
}
|
||
|
||
function fromForm(form: ThresholdForm): Partial<ThresholdSettings> {
|
||
return {
|
||
near_full_exit_fraction: Number(form.exitPercent) / 100,
|
||
loss_window_hours: Number(form.windowHours),
|
||
streak_length: Number(form.streakLength),
|
||
single_trade_loss_threshold: -Number(form.singleLossPercent) / 100,
|
||
streak_sum_loss_threshold: -Number(form.streakLossPercent) / 100,
|
||
cooldown_minutes: Number(form.cooldownMinutes),
|
||
};
|
||
}
|
||
|
||
function SettingsPanel({
|
||
settings,
|
||
status,
|
||
onSave,
|
||
}: {
|
||
settings: ThresholdSettings | undefined;
|
||
status: 'loading' | 'idle' | 'saving';
|
||
onSave: (form: ThresholdForm) => void;
|
||
}) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
const [form, setForm] = useState<ThresholdForm>(() => toForm(settings ?? DEFAULT_THRESHOLD_SETTINGS));
|
||
|
||
useEffect(() => {
|
||
if (settings) setForm(toForm(settings));
|
||
}, [settings]);
|
||
|
||
function field(key: keyof ThresholdForm, value: string): void {
|
||
setForm((prev) => ({ ...prev, [key]: value }));
|
||
}
|
||
|
||
if (!expanded) {
|
||
return (
|
||
<button type="button" className="settings-toggle" onClick={() => setExpanded(true)}>
|
||
<span>Settings</span>
|
||
<span aria-hidden="true">→</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section className="settings-panel">
|
||
<div className="settings-panel-header">
|
||
<h2>Settings</h2>
|
||
<button type="button" className="settings-collapse" onClick={() => setExpanded(false)}>
|
||
Done
|
||
</button>
|
||
</div>
|
||
<h3>Loss thresholds</h3>
|
||
|
||
<label>
|
||
Exit counts as "full" at
|
||
<div className="threshold-input">
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={100}
|
||
value={form.exitPercent}
|
||
onChange={(e) => field('exitPercent', e.target.value)}
|
||
/>
|
||
%
|
||
</div>
|
||
</label>
|
||
|
||
<label>
|
||
Streak window
|
||
<div className="threshold-input">
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={168}
|
||
value={form.windowHours}
|
||
onChange={(e) => field('windowHours', e.target.value)}
|
||
/>
|
||
hours
|
||
</div>
|
||
</label>
|
||
|
||
<label>
|
||
Streak length
|
||
<div className="threshold-input">
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={20}
|
||
value={form.streakLength}
|
||
onChange={(e) => field('streakLength', e.target.value)}
|
||
/>
|
||
trades
|
||
</div>
|
||
</label>
|
||
|
||
<label>
|
||
Single-trade loss locks at
|
||
<div className="threshold-input">
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={100}
|
||
value={form.singleLossPercent}
|
||
onChange={(e) => field('singleLossPercent', e.target.value)}
|
||
/>
|
||
%
|
||
</div>
|
||
</label>
|
||
|
||
<label>
|
||
Streak cumulative loss locks at
|
||
<div className="threshold-input">
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={100}
|
||
value={form.streakLossPercent}
|
||
onChange={(e) => field('streakLossPercent', e.target.value)}
|
||
/>
|
||
%
|
||
</div>
|
||
</label>
|
||
|
||
<label>
|
||
Auto-unlock after
|
||
<div className="threshold-input">
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={1440}
|
||
value={form.cooldownMinutes}
|
||
onChange={(e) => field('cooldownMinutes', e.target.value)}
|
||
/>
|
||
minutes
|
||
</div>
|
||
</label>
|
||
|
||
<button
|
||
type="button"
|
||
className="primary-button"
|
||
onClick={() => onSave(form)}
|
||
disabled={status === 'saving' || !settings}
|
||
>
|
||
{status === 'saving' ? 'Saving…' : 'Save'}
|
||
</button>
|
||
</section>
|
||
);
|
||
}
|