Files
copilot/src/entrypoints/popup/App.tsx
T
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

315 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}