feat(ui): the app shell: lock screen, sidebar, network switcher, status bar, settings
ci / gate (push) Successful in 9m14s

The frame every screen sits in. A lock screen fronts everything while the
session is locked: pick a wallet file, type a password, and the password
goes to Rust and nowhere else. Unlocked, a sidebar carries the sections,
the network switcher and a lock button with the minutes until the idle rule
fires; a status bar shows the chain in use, its endpoint, best and finalized
heights and spec version, and says "connecting…" or the last error
otherwise. Testnets are labelled in the switcher, the status bar and above
their content, as on the observer: a balance on a testnet is not money and
the chrome says so before the number does.

Settings persist as JSON in the app-data directory, written atomically:
network, idle lock, whether testnets are offered. Changing the network
re-keys every query; changing the idle lock takes effect on the next check.
The theme (system, dark, light) is a local preference applied before first
paint from the entry point, because the CSP allows no inline script.

Design tokens are the observer's, lifted whole so the wallet and the
scoreboard read as one family. The accounts page shows spendable and total
per account with the block the numbers were read at and how far that is
from final, refreshed by Rust on every head; send, receive and history are
placeholders naming their issues.

Wide integers now cross the IPC typed as `number`: they are JSON numbers on
the wire, which `bigint` cannot be serialised to, and none of them (block
heights, seconds, nonces) approaches 2^53. Amounts still travel as strings.

Closes #25

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
This commit is contained in:
2026-09-15 23:08:01 +03:00
co-authored by Claude Fable 5.1
parent be2644f31b
commit fbb7c71db9
32 changed files with 1165 additions and 37 deletions
+38
View File
@@ -10,6 +10,7 @@ use std::time::Instant;
use wallet_core::{ChainAdapter, SignedBy, TransferIntent, TxStatus};
use wallet_data::substrate::SubstrateAdapter;
use wallet_entities::Settings;
use tauri::State;
use wallet_core::backup::BackupChallenge;
@@ -646,3 +647,40 @@ pub async fn reversible_pending(
let adapter = adapter_for(&state, &chain)?;
adapter.pending_reversible(me).await.map_err(chain_error)
}
#[tauri::command]
pub fn settings_get(state: State<'_, AppState>) -> Result<Settings, WalletError> {
state
.settings
.load()
.map_err(|e| WalletError::Internal(e.to_string()))
}
/// Persist settings and apply the ones that take effect immediately. A
/// network the registry does not know is refused; an idle lock under
/// thirty seconds is refused, since it would lock mid-signature.
#[tauri::command]
pub fn settings_set(
state: State<'_, AppState>,
settings: Settings,
) -> Result<Settings, WalletError> {
if state.profiles.get(&settings.network).is_none() {
return Err(WalletError::InvalidInput(format!(
"unknown network {}",
settings.network.0
)));
}
if settings.idle_lock_seconds < 30 {
return Err(WalletError::InvalidInput(
"the idle lock must be at least 30 seconds".into(),
));
}
state
.settings
.save(&settings)
.map_err(|e| WalletError::Internal(e.to_string()))?;
state
.session
.set_idle_interval(std::time::Duration::from_secs(settings.idle_lock_seconds));
Ok(settings)
}
+2
View File
@@ -57,6 +57,8 @@ pub fn run() {
commands::transfer_discard,
commands::reversible_cancel_prepare,
commands::reversible_pending,
commands::settings_get,
commands::settings_set,
])
.run(tauri::generate_context!())
.expect("the Tauri runtime failed to start");
+10 -4
View File
@@ -12,6 +12,7 @@ use wallet_core::backup::BackupChallenge;
use wallet_core::profiles::ProfileRegistry;
use wallet_core::session::Session;
use wallet_data::keystore::FileKeystore;
use wallet_data::settings::SettingsStore;
use wallet_data::substrate::ChainManager;
use wallet_entities::ChainId;
use wallet_entities::SignatureScheme;
@@ -25,9 +26,6 @@ pub struct PendingCreation {
pub scheme: SignatureScheme,
}
/// Until the settings store exists (#25), the idle interval is fixed.
pub const DEFAULT_IDLE_LOCK: Duration = Duration::from_secs(10 * 60);
pub struct AppState {
pub keystore: FileKeystore,
pub pending_creation: Mutex<Option<PendingCreation>>,
@@ -44,6 +42,7 @@ pub struct AppState {
/// A submit consumes one; a new prepare for the same purpose replaces
/// nothing, it just adds, and stale ones are refused by the chain.
pub prepared: Mutex<HashMap<String, PreparedTransaction>>,
pub settings: SettingsStore,
}
impl AppState {
@@ -65,6 +64,12 @@ impl AppState {
None => FileKeystore::default_dir()
.map(|d| d.parent().map(|p| p.join("profiles.toml")).unwrap_or(d)),
};
let settings = SettingsStore::at(
FileKeystore::default_dir()
.and_then(|d| d.parent().map(|p| p.join("settings.json")))
.unwrap_or_else(|| std::path::PathBuf::from("settings.json")),
);
let loaded = settings.load()?;
let mut profiles = ProfileRegistry::shipped()?;
if let Some(path) = override_path {
if path.exists() {
@@ -76,11 +81,12 @@ impl AppState {
Ok(Self {
keystore: FileKeystore::in_dir(dir),
pending_creation: Mutex::new(None),
session: Session::new(DEFAULT_IDLE_LOCK),
session: Session::new(Duration::from_secs(loaded.idle_lock_seconds.max(30))),
profiles,
chains: Mutex::new(HashMap::new()),
balance_watchers: Mutex::new(HashMap::new()),
prepared: Mutex::new(HashMap::new()),
settings,
})
}
}
+10 -5
View File
@@ -51,20 +51,25 @@ pub enum SessionError {
}
pub struct Session {
idle: Duration,
idle: Mutex<Duration>,
inner: Mutex<Option<Unlocked>>,
}
impl Session {
pub fn new(idle: Duration) -> Self {
Self {
idle,
idle: Mutex::new(idle),
inner: Mutex::new(None),
}
}
pub fn idle_interval(&self) -> Duration {
self.idle
*self.idle.lock().unwrap_or_else(|p| p.into_inner())
}
/// Change the idle interval; applies from the next check.
pub fn set_idle_interval(&self, idle: Duration) {
*self.idle.lock().unwrap_or_else(|p| p.into_inner()) = idle;
}
/// Open a wallet. Anything previously open is dropped first. The
@@ -99,7 +104,7 @@ impl Session {
.as_ref()
.map(|u| now.saturating_duration_since(u.last_used));
match idle_for {
Some(d) if d >= self.idle => {
Some(d) if d >= self.idle_interval() => {
*guard = None;
Ok(true)
}
@@ -131,7 +136,7 @@ impl Session {
})
.collect(),
locks_in: Some(
self.idle
self.idle_interval()
.saturating_sub(now.saturating_duration_since(u.last_used))
.as_secs(),
),
+1
View File
@@ -7,4 +7,5 @@
pub mod keys;
pub mod keystore;
pub mod mnemonic;
pub mod settings;
pub mod substrate;
+91
View File
@@ -0,0 +1,91 @@
//! Settings on disk: a small JSON file in the app-data directory, written
//! atomically. Nothing secret lives here; a stranger reading it learns which
//! network the person uses and how long the wallet stays unlocked.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use wallet_entities::Settings;
#[derive(Debug, thiserror::Error)]
pub enum SettingsError {
#[error("settings file: {0}")]
Io(String),
#[error("settings file is not valid: {0}")]
Format(String),
}
#[derive(Debug, Clone)]
pub struct SettingsStore {
path: PathBuf,
}
impl SettingsStore {
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
/// Defaults when the file is missing; an unreadable or malformed file
/// is an error rather than silently reset, because losing a setting is
/// a thing a person should hear about.
pub fn load(&self) -> Result<Settings, SettingsError> {
match fs::read(&self.path) {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| SettingsError::Format(e.to_string()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
Err(e) => Err(SettingsError::Io(format!("{}: {e}", self.path.display()))),
}
}
pub fn save(&self, settings: &Settings) -> Result<(), SettingsError> {
if let Some(dir) = self.path.parent() {
fs::create_dir_all(dir)
.map_err(|e| SettingsError::Io(format!("{}: {e}", dir.display())))?;
}
let text = serde_json::to_vec_pretty(settings)
.map_err(|e| SettingsError::Format(e.to_string()))?;
let dir = self.path.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile::Builder::new()
.prefix(".settings-")
.suffix(".tmp")
.tempfile_in(dir)
.map_err(|e| SettingsError::Io(e.to_string()))?;
tmp.write_all(&text)
.map_err(|e| SettingsError::Io(e.to_string()))?;
tmp.as_file()
.sync_all()
.map_err(|e| SettingsError::Io(e.to_string()))?;
tmp.persist(&self.path)
.map_err(|e| SettingsError::Io(format!("{}: {}", self.path.display(), e.error)))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use wallet_entities::ChainId;
#[test]
fn defaults_then_round_trips_then_refuses_garbage() {
let dir = tempfile::tempdir().unwrap();
let store = SettingsStore::at(dir.path().join("settings.json"));
let s = store.load().unwrap();
assert_eq!(s, Settings::default());
let changed = Settings {
network: ChainId("planck".into()),
idle_lock_seconds: 120,
show_testnets: true,
};
store.save(&changed).unwrap();
assert_eq!(store.load().unwrap(), changed);
fs::write(store.path(), b"{ not json").unwrap();
assert!(matches!(store.load(), Err(SettingsError::Format(_))));
}
}
+43
View File
@@ -6,6 +6,11 @@
//! chain is a [`ChainId`] and a profile somewhere in `wallet-core`; an asset is
//! a [`AssetId`]; a quantity is an [`Amount`] of integer base units. Floats do
//! not appear, and never will: a wallet that rounds is a wallet that lies.
//!
//! Integers wider than 32 bits are typed `number` on the TypeScript side:
//! they cross the IPC as JSON numbers, which `bigint` cannot be serialised
//! to, and none of them (block heights, seconds, nonces) approaches 2^53.
//! Amounts are the exception and travel as strings.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -68,9 +73,12 @@ pub struct ChainStatus {
pub endpoint: Option<String>,
pub spec_version: Option<u32>,
pub transaction_version: Option<u32>,
#[ts(type = "number")]
pub best_number: Option<u64>,
#[ts(type = "number")]
pub finalized_number: Option<u64>,
/// Seconds since the last best head arrived; `None` when never.
#[ts(type = "number")]
pub head_age_seconds: Option<u64>,
/// The last error, when disconnected.
pub last_error: Option<String>,
@@ -83,7 +91,9 @@ pub struct ChainStatus {
#[ts(export)]
pub struct HighSecurityInfo {
pub guardian: String,
#[ts(type = "number")]
pub delay_blocks: Option<u64>,
#[ts(type = "number")]
pub delay_ms: Option<u64>,
}
@@ -101,12 +111,15 @@ pub struct AccountBalance {
pub spendable: Amount,
/// Free plus reserved.
pub total: Amount,
#[ts(type = "number")]
pub nonce: u64,
pub high_security: Option<HighSecurityInfo>,
/// The best block the numbers were read at.
#[ts(type = "number")]
pub at_block: u64,
/// The chain's finalized height at the time, so the UI can say how
/// many confirmations deep `at_block` is.
#[ts(type = "number")]
pub finalized_block: Option<u64>,
}
@@ -139,7 +152,9 @@ pub struct PendingReversible {
pub amount: Amount,
/// When it executes, as the scheduler records it: a block number, or a
/// timestamp in milliseconds, whichever the delay was given in.
#[ts(type = "number")]
pub execute_at_block: Option<u64>,
#[ts(type = "number")]
pub execute_at_ms: Option<u64>,
/// Whether the open wallet's account may cancel it (sender for a
/// one-time delay, guardian for a high-security account).
@@ -156,8 +171,10 @@ pub struct PreparedTransferInfo {
pub from: String,
pub summary: CallSummary,
pub fee: Amount,
#[ts(type = "number")]
pub nonce: u64,
/// Blocks the transaction stays valid for from the block it was built at.
#[ts(type = "number")]
pub valid_for_blocks: u64,
}
@@ -358,6 +375,7 @@ pub struct WalletSummary {
pub address: String,
pub kind: WalletKind,
/// Unix seconds; the file's `created_at`.
#[ts(type = "number")]
pub created_at: i64,
}
@@ -395,9 +413,34 @@ pub struct SessionStatusInfo {
pub locked: bool,
pub wallet: Option<WalletSummary>,
pub accounts: Vec<SessionAccountInfo>,
#[ts(type = "number")]
pub locks_in_seconds: Option<u64>,
}
/// Persisted preferences. Nothing here is secret.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(default)]
#[ts(export)]
pub struct Settings {
/// The chain the UI is showing.
pub network: ChainId,
/// Idle time before the session locks itself.
#[ts(type = "number")]
pub idle_lock_seconds: u64,
/// Whether testnet profiles are offered in the switcher.
pub show_testnets: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
network: ChainId("quantus".into()),
idle_lock_seconds: 10 * 60,
show_testnets: false,
}
}
}
/// What the app tells the UI about itself at startup.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
+28 -9
View File
@@ -1,16 +1,35 @@
import { useAppInfo } from './api/hooks'
import { HashRouter, Route, Routes } from 'react-router-dom'
import { useSession, useSessionLockedListener } from './api/hooks'
import { LockScreen } from './components/LockScreen'
import { Sidebar } from './components/Sidebar'
import { StatusBar } from './components/StatusBar'
import { Accounts } from './routes/Accounts'
import { Placeholder } from './routes/Placeholder'
import { SettingsRoute } from './routes/Settings'
// The webview renders; it never holds a key, decodes a transaction, or does
// arithmetic on an amount. Everything it shows came from a Rust command.
export default function App() {
const info = useAppInfo()
useSessionLockedListener()
const session = useSession()
if (session.isPending) return <div className="lock muted">starting…</div>
if (session.isError) return <div className="lock error">{session.error.message}</div>
if (session.data.locked) return <LockScreen />
return (
<main>
<h1>blackbeard wallet</h1>
{info.isPending && <p>starting…</p>}
{info.isError && <p role="alert">{info.error.message}</p>}
{info.isSuccess && <p>v{info.data.version}</p>}
</main>
<HashRouter>
<div className="shell">
<Sidebar />
<main className="main">
<Routes>
<Route path="/" element={<Accounts />} />
<Route path="/send" element={<Placeholder title="Send" issue={28} />} />
<Route path="/receive" element={<Placeholder title="Receive" issue={27} />} />
<Route path="/history" element={<Placeholder title="History" issue={30} />} />
<Route path="/settings" element={<SettingsRoute />} />
</Routes>
</main>
<StatusBar />
</div>
</HashRouter>
)
}
+20
View File
@@ -0,0 +1,20 @@
// Events Rust emits. One subscription helper per event so a screen never
// spells an event name itself.
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { AccountBalance } from './generated/AccountBalance'
import type { TxStatusInfo } from './generated/TxStatusInfo'
export function onSessionLocked(handler: () => void): Promise<UnlistenFn> {
return listen('session:locked', () => handler())
}
export function onBalancesUpdated(
handler: (balances: AccountBalance[]) => void,
): Promise<UnlistenFn> {
return listen<AccountBalance[]>('balances:updated', (e) => handler(e.payload))
}
export function onTxStatus(handler: (status: TxStatusInfo) => void): Promise<UnlistenFn> {
return listen<TxStatusInfo>('tx:status', (e) => handler(e.payload))
}
+3 -3
View File
@@ -14,13 +14,13 @@ spendable: Amount,
/**
* Free plus reserved.
*/
total: Amount, nonce: bigint, high_security: HighSecurityInfo | null,
total: Amount, nonce: number, high_security: HighSecurityInfo | null,
/**
* The best block the numbers were read at.
*/
at_block: bigint,
at_block: number,
/**
* The chain's finalized height at the time, so the UI can say how
* many confirmations deep `at_block` is.
*/
finalized_block: bigint | null, };
finalized_block: number, };
+2 -2
View File
@@ -9,11 +9,11 @@ export type ChainStatus = { chain: ChainId, connected: boolean,
/**
* The endpoint currently in use, or the last one tried.
*/
endpoint: string | null, spec_version: number | null, transaction_version: number | null, best_number: bigint | null, finalized_number: bigint | null,
endpoint: string | null, spec_version: number | null, transaction_version: number | null, best_number: number, finalized_number: number,
/**
* Seconds since the last best head arrived; `None` when never.
*/
head_age_seconds: bigint | null,
head_age_seconds: number,
/**
* The last error, when disconnected.
*/
+1 -1
View File
@@ -3,4 +3,4 @@
/**
* A high-security account's guardian and cancellation delay.
*/
export type HighSecurityInfo = { guardian: string, delay_blocks: bigint | null, delay_ms: bigint | null, };
export type HighSecurityInfo = { guardian: string, delay_blocks: number, delay_ms: number, };
+1 -1
View File
@@ -9,7 +9,7 @@ export type PendingReversible = { tx_id: string, from: string, to: string, guard
* When it executes, as the scheduler records it: a block number, or a
* timestamp in milliseconds, whichever the delay was given in.
*/
execute_at_block: bigint | null, execute_at_ms: bigint | null,
execute_at_block: number, execute_at_ms: number,
/**
* Whether the open wallet's account may cancel it (sender for a
* one-time delay, guardian for a high-security account).
+2 -2
View File
@@ -7,8 +7,8 @@ import type { ChainId } from "./ChainId";
* A transaction prepared for review: what will be signed, decoded from
* the bytes, and what it costs. `id` is handed back to submit it.
*/
export type PreparedTransferInfo = { id: string, chain: ChainId, from: string, summary: CallSummary, fee: Amount, nonce: bigint,
export type PreparedTransferInfo = { id: string, chain: ChainId, from: string, summary: CallSummary, fee: Amount, nonce: number,
/**
* Blocks the transaction stays valid for from the block it was built at.
*/
valid_for_blocks: bigint, };
valid_for_blocks: number, };
+1 -1
View File
@@ -6,4 +6,4 @@ import type { WalletSummary } from "./WalletSummary";
* Whether a wallet is open, which one, and how long until the idle rule
* locks it again.
*/
export type SessionStatusInfo = { locked: boolean, wallet: WalletSummary | null, accounts: Array<SessionAccountInfo>, locks_in_seconds: bigint | null, };
export type SessionStatusInfo = { locked: boolean, wallet: WalletSummary | null, accounts: Array<SessionAccountInfo>, locks_in_seconds: number, };
+19
View File
@@ -0,0 +1,19 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ChainId } from "./ChainId";
/**
* Persisted preferences. Nothing here is secret.
*/
export type Settings = {
/**
* The chain the UI is showing.
*/
network: ChainId,
/**
* Idle time before the session locks itself.
*/
idle_lock_seconds: number,
/**
* Whether testnet profiles are offered in the switcher.
*/
show_testnets: boolean, };
+1 -1
View File
@@ -8,4 +8,4 @@ export type WalletSummary = { name: string, address: string, kind: WalletKind,
/**
* Unix seconds; the file's `created_at`.
*/
created_at: bigint, };
created_at: number, };
+110 -2
View File
@@ -1,13 +1,121 @@
// React Query wrappers around the command client. Server state (what Rust
// knows) lives in the query cache; component state stays local.
import { useQuery } from '@tanstack/react-query'
import { appInfo } from './wallet'
import { useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { ChainId } from './generated/ChainId'
import type { Settings } from './generated/Settings'
import { onBalancesUpdated, onSessionLocked } from './events'
import {
appInfo,
balances,
balancesWatch,
chainConnect,
chainStatus,
chainsList,
lock,
sessionStatus,
settingsGet,
settingsSet,
unlock,
walletsList,
} from './wallet'
export const queryKeys = {
appInfo: ['app-info'] as const,
settings: ['settings'] as const,
chains: ['chains'] as const,
session: ['session'] as const,
wallets: ['wallets'] as const,
chainStatus: (chain: ChainId) => ['chain-status', chain] as const,
balances: (chain: ChainId) => ['balances', chain] as const,
}
export function useAppInfo() {
return useQuery({ queryKey: queryKeys.appInfo, queryFn: appInfo, staleTime: Infinity })
}
export function useSettings() {
return useQuery({ queryKey: queryKeys.settings, queryFn: settingsGet, staleTime: Infinity })
}
export function useSetSettings() {
const qc = useQueryClient()
return useMutation({
mutationFn: (s: Settings) => settingsSet(s),
onSuccess: (s) => qc.setQueryData(queryKeys.settings, s),
})
}
export function useChains() {
return useQuery({ queryKey: queryKeys.chains, queryFn: chainsList, staleTime: Infinity })
}
export function useWallets() {
return useQuery({ queryKey: queryKeys.wallets, queryFn: walletsList })
}
export function useSession() {
return useQuery({ queryKey: queryKeys.session, queryFn: sessionStatus, refetchInterval: 15_000 })
}
export function useUnlock() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ name, password }: { name: string; password: string }) => unlock(name, password),
onSuccess: (s) => qc.setQueryData(queryKeys.session, s),
})
}
export function useLock() {
const qc = useQueryClient()
return useMutation({
mutationFn: lock,
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.session }),
})
}
/// Connect the chain (idempotent on the Rust side) and poll its status.
export function useChainStatus(chain: ChainId | undefined) {
return useQuery({
queryKey: chain ? queryKeys.chainStatus(chain) : ['chain-status', 'none'],
queryFn: () => chainConnect(chain!).then(() => chainStatus(chain!)),
enabled: !!chain,
refetchInterval: 5_000,
})
}
/// Balances of the open wallet on `chain`, refreshed by Rust on every head.
export function useBalances(chain: ChainId | undefined, unlocked: boolean) {
const qc = useQueryClient()
useEffect(() => {
if (!chain || !unlocked) return
let stop: (() => void) | undefined
balancesWatch(chain).catch(() => undefined)
onBalancesUpdated((b) => {
if (b.length > 0 && b[0].chain === chain) qc.setQueryData(queryKeys.balances(chain), b)
}).then((un) => {
stop = un
})
return () => stop?.()
}, [chain, unlocked, qc])
return useQuery({
queryKey: chain ? queryKeys.balances(chain) : ['balances', 'none'],
queryFn: () => balances(chain!),
enabled: !!chain && unlocked,
retry: 2,
retryDelay: 1_500,
})
}
/// Drop the session in the cache when Rust says the idle rule fired.
export function useSessionLockedListener() {
const qc = useQueryClient()
useEffect(() => {
let stop: (() => void) | undefined
onSessionLocked(() => qc.invalidateQueries({ queryKey: queryKeys.session })).then((un) => {
stop = un
})
return () => stop?.()
}, [qc])
}
+9
View File
@@ -13,6 +13,7 @@ import type { PendingReversible } from './generated/PendingReversible'
import type { PreparedTransferInfo } from './generated/PreparedTransferInfo'
import type { CreationStart } from './generated/CreationStart'
import type { SessionStatusInfo } from './generated/SessionStatusInfo'
import type { Settings } from './generated/Settings'
import type { SignatureScheme } from './generated/SignatureScheme'
import type { WalletSummary } from './generated/WalletSummary'
import type { WalletError as WalletErrorShape } from './generated/WalletError'
@@ -169,3 +170,11 @@ export function reversibleCancelPrepare(
): Promise<PreparedTransferInfo> {
return command<PreparedTransferInfo>('reversible_cancel_prepare', { chain, from, txId })
}
export function settingsGet(): Promise<Settings> {
return command<Settings>('settings_get')
}
export function settingsSet(settings: Settings): Promise<Settings> {
return command<Settings>('settings_set', { settings })
}
+14
View File
@@ -0,0 +1,14 @@
import { useChains, useSettings } from '../api/hooks'
/** A one-line warning above testnet content: this is not money. */
export function ChainCaveat() {
const settings = useSettings()
const chains = useChains()
const profile = chains.data?.find((c) => c.id === settings.data?.network)
if (!profile || profile.kind === 'mainnet') return null
return (
<p className="warn" style={{ marginTop: 0 }}>
{profile.display_name} is a {profile.kind}: its {profile.token_symbol} has no value.
</p>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { useState, type FormEvent } from 'react'
import { useUnlock, useWallets } from '../api/hooks'
/**
* Fronts everything while the session is locked. Picks a wallet file and
* takes a password; the password goes to Rust and nowhere else.
*/
export function LockScreen() {
const wallets = useWallets()
const unlock = useUnlock()
const [name, setName] = useState<string>('')
const [password, setPassword] = useState('')
const list = wallets.data ?? []
const selected = name || list[0]?.name || ''
function submit(e: FormEvent) {
e.preventDefault()
if (!selected) return
unlock.mutate({ name: selected, password }, { onSettled: () => setPassword('') })
}
return (
<div className="lock">
<form className="panel" onSubmit={submit}>
<div className="panel-head">
<h1 className="panel-title">Unlock</h1>
<span className="eyebrow">blackbeard wallet</span>
</div>
<div className="panel-body">
{wallets.isPending && <p className="muted">Reading wallets…</p>}
{wallets.isError && <p className="error">{wallets.error.message}</p>}
{wallets.isSuccess && list.length === 0 && (
<p className="muted">
No wallets yet. Creating and importing arrive with the onboarding flow; until then,
place a quantus-cli wallet file in the keystore directory.
</p>
)}
{list.length > 0 && (
<>
<div className="field">
<label htmlFor="wallet">Wallet</label>
<select
id="wallet"
className="select"
value={selected}
onChange={(e) => setName(e.target.value)}
>
{list.map((w) => (
<option key={w.name} value={w.name}>
{w.name} · {w.address.slice(0, 8)}…
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="password">Password</label>
<input
id="password"
className="input"
type="password"
autoComplete="current-password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{unlock.isError && <p className="error">{unlock.error.message}</p>}
<div className="row">
<button className="button primary" type="submit" disabled={unlock.isPending}>
{unlock.isPending ? 'Unlocking…' : 'Unlock'}
</button>
</div>
</>
)}
</div>
</form>
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { useChains, useSetSettings, useSettings } from '../api/hooks'
/**
* Mainnet and testnets are separated and labelled, as on the observer: a
* balance on a testnet is not money, and the chrome should say so before
* the number does. Testnets only appear when settings allow them.
*/
export function NetworkSwitcher() {
const chains = useChains()
const settings = useSettings()
const set = useSetSettings()
if (!chains.data || !settings.data) return null
const showTestnets = settings.data.show_testnets
const visible = chains.data.filter((c) => c.kind === 'mainnet' || showTestnets)
const current = settings.data.network
return (
<div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="network" className="eyebrow">
Network
</label>
<select
id="network"
className="select"
value={current}
onChange={(e) => set.mutate({ ...settings.data, network: e.target.value })}
>
{visible.map((c) => (
<option key={c.id} value={c.id}>
{c.display_name}
{c.kind !== 'mainnet' ? ` (${c.kind})` : ''}
</option>
))}
</select>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { NavLink } from 'react-router-dom'
import { useLock, useSession } from '../api/hooks'
import { NetworkSwitcher } from './NetworkSwitcher'
const SECTIONS = [
{ to: '/', label: 'Accounts' },
{ to: '/send', label: 'Send' },
{ to: '/receive', label: 'Receive' },
{ to: '/history', label: 'History' },
{ to: '/settings', label: 'Settings' },
]
export function Sidebar() {
const session = useSession()
const lock = useLock()
return (
<aside className="sidebar">
<div>
<div className="mark-name">
black<span>beard</span>
</div>
<div className="mark-tagline">wallet</div>
</div>
<nav className="sections" aria-label="Section">
{SECTIONS.map((s) => (
<NavLink
key={s.to}
to={s.to}
end={s.to === '/'}
className="section-link"
aria-current={undefined}
>
{s.label}
</NavLink>
))}
</nav>
<div className="sidebar-foot">
<NetworkSwitcher />
{session.data?.wallet && (
<div className="row" style={{ justifyContent: 'space-between' }}>
<span className="muted" style={{ fontSize: 12 }}>
{session.data.wallet.name}
{session.data.locks_in_seconds != null && (
<> · locks in {Math.ceil(session.data.locks_in_seconds / 60)} min</>
)}
</span>
<button className="button" onClick={() => lock.mutate()}>
Lock
</button>
</div>
)}
</div>
</aside>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { useChainStatus, useChains, useSettings } from '../api/hooks'
export function StatusBar() {
const settings = useSettings()
const chains = useChains()
const status = useChainStatus(settings.data?.network)
const profile = chains.data?.find((c) => c.id === settings.data?.network)
const s = status.data
return (
<footer className="statusbar">
<span>
<span className={`dot ${s?.connected ? 'on' : s ? 'off' : ''}`} />
{profile?.display_name ?? '…'}
{profile && profile.kind !== 'mainnet' && (
<>
{' '}
<span className="chip testnet">{profile.kind}</span>
</>
)}
</span>
{s?.connected ? (
<>
<span className="mono">{s.endpoint}</span>
<span className="numeral">
best #{s.best_number ?? '…'} · final #{s.finalized_number ?? '…'}
</span>
<span className="numeral">spec {s.spec_version}</span>
</>
) : (
<span className="muted">{s?.last_error ?? 'connecting…'}</span>
)}
</footer>
)
}
+297 -6
View File
@@ -1,12 +1,303 @@
:root {
color-scheme: light dark;
font-family: system-ui, sans-serif;
}
@import './tokens.css';
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: radial-gradient(120% 60% at 50% -10%, var(--glow), transparent 60%), var(--surface-0);
background-attachment: fixed;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
/* A desktop app, not a page: no text selection on chrome, no rubber-band. */
overscroll-behavior: none;
}
a {
color: inherit;
text-decoration: none;
}
:focus-visible {
outline: 2px solid var(--data-bright);
outline-offset: 2px;
border-radius: 2px;
}
button,
input,
select {
font: inherit;
color: inherit;
}
main {
padding: 2rem;
/* ---- type ---------------------------------------------------------------- */
.display {
font-family: var(--font-display);
font-weight: 700;
letter-spacing: 0.02em;
}
.eyebrow {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-muted);
}
.numeral {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.mono {
font-family: var(--font-mono);
font-size: 13px;
overflow-wrap: anywhere;
}
.muted {
color: var(--text-muted);
}
.good {
color: var(--good);
}
.warn {
color: var(--warn);
}
.bad {
color: var(--accent);
}
/* ---- shell --------------------------------------------------------------- */
.shell {
display: grid;
grid-template-columns: 220px 1fr;
grid-template-rows: 1fr auto;
height: 100%;
}
.sidebar {
grid-row: 1 / span 2;
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px 16px;
background: var(--surface-1);
border-right: var(--rule);
user-select: none;
}
.mark-name {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1;
}
.mark-name span {
color: var(--data);
}
.mark-tagline {
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-muted);
margin-top: 6px;
}
.sections {
display: flex;
flex-direction: column;
gap: 2px;
}
.section-link {
padding: 8px 10px;
border-radius: 3px;
color: var(--text-secondary);
}
.section-link:hover {
background: var(--surface-2);
color: var(--text-primary);
}
.section-link[aria-current='page'] {
background: var(--data-wash);
color: var(--text-primary);
box-shadow: inset 2px 0 0 var(--data);
}
.sidebar-foot {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.main {
padding: 24px 28px 40px;
overflow: auto;
}
.statusbar {
grid-column: 2;
display: flex;
gap: 18px;
align-items: center;
padding: 6px 16px;
border-top: var(--rule);
background: var(--surface-1);
font-size: 12px;
color: var(--text-secondary);
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
margin-right: 6px;
vertical-align: middle;
}
.dot.on {
background: var(--good);
box-shadow: 0 0 0 3px var(--good-halo);
}
.dot.off {
background: var(--accent);
}
/* ---- panels and forms ---------------------------------------------------- */
.panel {
background: var(--surface-1);
border: var(--rule);
box-shadow: var(--shadow);
margin-bottom: 18px;
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 12px 18px;
border-bottom: var(--rule);
}
.panel-title {
font-family: var(--font-display);
font-size: 16px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
margin: 0;
}
.panel-body {
padding: 16px 18px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.field label {
font-size: 12px;
color: var(--text-secondary);
}
.input,
.select {
padding: 8px 10px;
background: var(--surface-0);
border: 1px solid var(--border-strong);
border-radius: 3px;
min-width: 0;
}
.input.mono {
font-size: 13px;
}
.button {
padding: 8px 14px;
border-radius: 3px;
border: 1px solid var(--border-strong);
background: var(--surface-2);
cursor: pointer;
}
.button:hover {
background: var(--surface-3);
}
.button.primary {
background: var(--data);
border-color: var(--data);
color: #14110d;
font-weight: 600;
}
.button.primary:hover {
background: var(--data-bright);
}
.button.danger {
border-color: var(--accent);
color: var(--accent);
}
.button:disabled {
opacity: 0.5;
cursor: default;
}
.row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.error {
color: var(--accent);
background: var(--accent-wash);
border: 1px solid var(--accent);
padding: 8px 12px;
border-radius: 3px;
margin: 10px 0;
}
.chip {
display: inline-block;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 999px;
background: var(--data-chip);
color: var(--text-primary);
}
.chip.testnet {
border: 1px dashed var(--warn-border);
background: transparent;
color: var(--warn);
}
/* ---- lock screen --------------------------------------------------------- */
.lock {
height: 100%;
display: grid;
place-items: center;
padding: 24px;
}
.lock .panel {
width: min(440px, 100%);
}
/* ---- tables -------------------------------------------------------------- */
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th {
text-align: left;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-muted);
padding: 8px 12px;
border-bottom: var(--rule);
}
.table td {
padding: 10px 12px;
border-bottom: var(--rule);
vertical-align: top;
}
.table td.num {
text-align: right;
}
+9
View File
@@ -0,0 +1,9 @@
// Rust renders amounts exactly; this only inserts thousands separators in
// the whole part for reading. No arithmetic happens here.
export function formatAmount(a: { raw: string; decimals: number }): string {
const raw = a.raw.padStart(a.decimals + 1, '0')
const whole = raw.slice(0, raw.length - a.decimals)
const frac = raw.slice(raw.length - a.decimals).replace(/0+$/, '')
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return frac ? `${grouped}.${frac}` : grouped
}
+29
View File
@@ -0,0 +1,29 @@
// Theme preference, applied before first paint from main.tsx. "auto" follows
// the system; the attribute is always a concrete value so CSS stays to one
// definition per palette.
export type ThemePreference = 'auto' | 'dark' | 'light'
const KEY = 'blackbeard.theme'
export function storedTheme(): ThemePreference {
try {
const v = localStorage.getItem(KEY)
return v === 'dark' || v === 'light' ? v : 'auto'
} catch {
return 'auto'
}
}
export function applyTheme(pref: ThemePreference) {
const system = window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', pref === 'auto' ? system : pref)
}
export function setTheme(pref: ThemePreference) {
try {
localStorage.setItem(KEY, pref)
} catch {
// A private window; the preference lasts the session.
}
applyTheme(pref)
}
+5
View File
@@ -2,8 +2,13 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App.tsx'
import { applyTheme, storedTheme } from './lib/theme'
import './index.css'
// Resolve the theme before the first paint; there is no inline script
// because the CSP allows none.
applyTheme(storedTheme())
const queryClient = new QueryClient({
defaultOptions: {
queries: {
+72
View File
@@ -0,0 +1,72 @@
import { useBalances, useSession, useSettings } from '../api/hooks'
import { ChainCaveat } from '../components/ChainCaveat'
import { formatAmount } from '../lib/format'
/**
* The open wallet's accounts on the selected network, with balances read
* at the best block and refreshed by Rust on every head. Confirmations are
* the distance to the finalized height.
*/
export function Accounts() {
const settings = useSettings()
const session = useSession()
const unlocked = !!session.data && !session.data.locked
const balances = useBalances(settings.data?.network, unlocked)
return (
<>
<ChainCaveat />
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Accounts</h2>
{balances.data?.[0] && (
<span className="muted" style={{ fontSize: 12 }}>
at block <span className="numeral">{balances.data[0].at_block}</span>
{balances.data[0].finalized_block != null && (
<>
{' '}
·{' '}
<span className="numeral">
{Math.max(0, balances.data[0].at_block - balances.data[0].finalized_block)}
</span>{' '}
from final
</>
)}
</span>
)}
</div>
{balances.isPending && <p className="panel-body muted">Reading balances from the chain…</p>}
{balances.isError && <p className="error">{balances.error.message}</p>}
{balances.isSuccess && (
<table className="table">
<thead>
<tr>
<th>Address</th>
<th>Scheme</th>
<th className="num">Spendable</th>
<th className="num">Total</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{balances.data.map((b, i) => (
<tr key={b.address}>
<td className="mono">{b.address}</td>
<td>{session.data?.accounts[i]?.scheme ?? ''}</td>
<td className="num numeral">
{b.spendable.raw === '0' ? '0' : formatAmount(b.spendable)} {b.symbol}
</td>
<td className="num numeral">
{formatAmount(b.total)} {b.symbol}
</td>
<td className="muted">
{b.high_security ? 'high-security account: transfers are delayed' : ''}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)
}
+10
View File
@@ -0,0 +1,10 @@
export function Placeholder({ title, issue }: { title: string; issue: number }) {
return (
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">{title}</h2>
</div>
<p className="panel-body muted">Arrives with issue #{issue}.</p>
</section>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from 'react'
import { useAppInfo, useSetSettings, useSettings } from '../api/hooks'
import { setTheme, storedTheme, type ThemePreference } from '../lib/theme'
export function SettingsRoute() {
const settings = useSettings()
const set = useSetSettings()
const info = useAppInfo()
const [theme, setThemeState] = useState<ThemePreference>(storedTheme())
if (!settings.data) return null
const s = settings.data
return (
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Settings</h2>
{info.data && <span className="muted">v{info.data.version}</span>}
</div>
<div className="panel-body">
<div className="field">
<label htmlFor="idle">Lock after idle (minutes)</label>
<input
id="idle"
className="input"
type="number"
min={1}
max={120}
value={Math.round(s.idle_lock_seconds / 60)}
onChange={(e) =>
set.mutate({
...s,
idle_lock_seconds: Math.max(1, Number(e.target.value)) * 60,
})
}
/>
</div>
<div className="field">
<label>
<input
type="checkbox"
checked={s.show_testnets}
onChange={(e) => set.mutate({ ...s, show_testnets: e.target.checked })}
/>{' '}
Offer testnets in the network switcher
</label>
</div>
<div className="field">
<label htmlFor="theme">Theme</label>
<select
id="theme"
className="select"
value={theme}
onChange={(e) => {
const v = e.target.value as ThemePreference
setThemeState(v)
setTheme(v)
}}
>
<option value="auto">Follow the system</option>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
{set.isError && <p className="error">{set.error.message}</p>}
</div>
</section>
)
}
+65
View File
@@ -0,0 +1,65 @@
/*
* Design tokens, lifted from blackbeard.observer's design system so the wallet
* and the scoreboard read as one family. Two palettes, both selected rather
* than computed from each other; see the observer's index.css for the
* reasoning and the validator commands behind every value. The wallet adds
* nothing to the palette: identity is bronze, alarm is crimson, and a balance
* is a numeral in tabular figures.
*/
:root,
:root[data-theme='dark'] {
color-scheme: dark;
--surface-0: #0d0b09;
--surface-1: #14110d;
--surface-2: #1c1813;
--surface-3: #262019;
--border: #2f2719;
--border-strong: #453923;
--text-primary: #f2ece0;
--text-secondary: #a2937c;
--text-muted: #7a6c59;
--data: #bd8829;
--data-bright: #e0a63a;
--data-wash: rgba(189, 136, 41, 0.1);
--accent: #d8453a;
--accent-bright: #f05a4c;
--accent-wash: rgba(216, 69, 58, 0.1);
--good: #3f9d76;
--warn: #d9a441;
--font-display: 'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif;
--font-body: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
--glow: rgba(189, 136, 41, 0.08);
--good-halo: rgba(63, 157, 118, 0.18);
--data-chip: rgba(189, 136, 41, 0.18);
--warn-border: rgba(217, 164, 65, 0.4);
--failed-chip: rgba(216, 69, 58, 0.12);
--rule: 1px solid var(--border);
--shadow: 0 1px 0 rgba(255, 255, 255, 0.03) inset;
}
:root[data-theme='light'] {
color-scheme: light;
--surface-0: #fbf8f2;
--surface-1: #f5f1e8;
--surface-2: #ece7da;
--surface-3: #e2dbc9;
--border: #d9d0ba;
--border-strong: #b8ab8d;
--text-primary: #1b1710;
--text-secondary: #5b5142;
--text-muted: #7c7263;
--data: #a8741c;
--data-bright: #8a5f10;
--data-wash: rgba(168, 116, 28, 0.12);
--accent: #c3382d;
--accent-bright: #a82c20;
--accent-wash: rgba(195, 56, 45, 0.1);
--good: #0f8a58;
--warn: #b8651a;
--glow: rgba(168, 116, 28, 0.09);
--good-halo: rgba(15, 138, 88, 0.16);
--data-chip: rgba(168, 116, 28, 0.16);
--warn-border: rgba(184, 101, 26, 0.45);
--failed-chip: rgba(195, 56, 45, 0.09);
--shadow: 0 1px 0 rgba(27, 23, 16, 0.04) inset;
}