From fbb7c71db9be9a2a579fad4c374e23f662cdf432 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 15 Sep 2026 23:08:01 +0300 Subject: [PATCH] feat(ui): the app shell: lock screen, sidebar, network switcher, status bar, settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW --- crates/wallet-app/src/commands.rs | 38 +++ crates/wallet-app/src/lib.rs | 2 + crates/wallet-app/src/state.rs | 14 +- crates/wallet-core/src/session.rs | 15 +- crates/wallet-data/src/lib.rs | 1 + crates/wallet-data/src/settings.rs | 91 ++++++ crates/wallet-entities/src/lib.rs | 43 +++ ui/src/App.tsx | 37 ++- ui/src/api/events.ts | 20 ++ ui/src/api/generated/AccountBalance.ts | 6 +- ui/src/api/generated/ChainStatus.ts | 4 +- ui/src/api/generated/HighSecurityInfo.ts | 2 +- ui/src/api/generated/PendingReversible.ts | 2 +- ui/src/api/generated/PreparedTransferInfo.ts | 4 +- ui/src/api/generated/SessionStatusInfo.ts | 2 +- ui/src/api/generated/Settings.ts | 19 ++ ui/src/api/generated/WalletSummary.ts | 2 +- ui/src/api/hooks.ts | 112 ++++++- ui/src/api/wallet.ts | 9 + ui/src/components/ChainCaveat.tsx | 14 + ui/src/components/LockScreen.tsx | 80 +++++ ui/src/components/NetworkSwitcher.tsx | 36 +++ ui/src/components/Sidebar.tsx | 55 ++++ ui/src/components/StatusBar.tsx | 34 +++ ui/src/index.css | 303 ++++++++++++++++++- ui/src/lib/format.ts | 9 + ui/src/lib/theme.ts | 29 ++ ui/src/main.tsx | 5 + ui/src/routes/Accounts.tsx | 72 +++++ ui/src/routes/Placeholder.tsx | 10 + ui/src/routes/Settings.tsx | 67 ++++ ui/src/tokens.css | 65 ++++ 32 files changed, 1165 insertions(+), 37 deletions(-) create mode 100644 crates/wallet-data/src/settings.rs create mode 100644 ui/src/api/events.ts create mode 100644 ui/src/api/generated/Settings.ts create mode 100644 ui/src/components/ChainCaveat.tsx create mode 100644 ui/src/components/LockScreen.tsx create mode 100644 ui/src/components/NetworkSwitcher.tsx create mode 100644 ui/src/components/Sidebar.tsx create mode 100644 ui/src/components/StatusBar.tsx create mode 100644 ui/src/lib/format.ts create mode 100644 ui/src/lib/theme.ts create mode 100644 ui/src/routes/Accounts.tsx create mode 100644 ui/src/routes/Placeholder.tsx create mode 100644 ui/src/routes/Settings.tsx create mode 100644 ui/src/tokens.css diff --git a/crates/wallet-app/src/commands.rs b/crates/wallet-app/src/commands.rs index bbc9d7d..0697b0e 100644 --- a/crates/wallet-app/src/commands.rs +++ b/crates/wallet-app/src/commands.rs @@ -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 { + 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 { + 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) +} diff --git a/crates/wallet-app/src/lib.rs b/crates/wallet-app/src/lib.rs index 4513738..c48606c 100644 --- a/crates/wallet-app/src/lib.rs +++ b/crates/wallet-app/src/lib.rs @@ -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"); diff --git a/crates/wallet-app/src/state.rs b/crates/wallet-app/src/state.rs index 2b042ea..9113a61 100644 --- a/crates/wallet-app/src/state.rs +++ b/crates/wallet-app/src/state.rs @@ -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>, @@ -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>, + 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, }) } } diff --git a/crates/wallet-core/src/session.rs b/crates/wallet-core/src/session.rs index fa1f929..657e58f 100644 --- a/crates/wallet-core/src/session.rs +++ b/crates/wallet-core/src/session.rs @@ -51,20 +51,25 @@ pub enum SessionError { } pub struct Session { - idle: Duration, + idle: Mutex, inner: Mutex>, } 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(), ), diff --git a/crates/wallet-data/src/lib.rs b/crates/wallet-data/src/lib.rs index be3dba1..f8c7555 100644 --- a/crates/wallet-data/src/lib.rs +++ b/crates/wallet-data/src/lib.rs @@ -7,4 +7,5 @@ pub mod keys; pub mod keystore; pub mod mnemonic; +pub mod settings; pub mod substrate; diff --git a/crates/wallet-data/src/settings.rs b/crates/wallet-data/src/settings.rs new file mode 100644 index 0000000..db27203 --- /dev/null +++ b/crates/wallet-data/src/settings.rs @@ -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) -> 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 { + 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(_)))); + } +} diff --git a/crates/wallet-entities/src/lib.rs b/crates/wallet-entities/src/lib.rs index c66c10d..71c97da 100644 --- a/crates/wallet-entities/src/lib.rs +++ b/crates/wallet-entities/src/lib.rs @@ -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, pub spec_version: Option, pub transaction_version: Option, + #[ts(type = "number")] pub best_number: Option, + #[ts(type = "number")] pub finalized_number: Option, /// Seconds since the last best head arrived; `None` when never. + #[ts(type = "number")] pub head_age_seconds: Option, /// The last error, when disconnected. pub last_error: Option, @@ -83,7 +91,9 @@ pub struct ChainStatus { #[ts(export)] pub struct HighSecurityInfo { pub guardian: String, + #[ts(type = "number")] pub delay_blocks: Option, + #[ts(type = "number")] pub delay_ms: Option, } @@ -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, /// 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, } @@ -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, + #[ts(type = "number")] pub execute_at_ms: Option, /// 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, pub accounts: Vec, + #[ts(type = "number")] pub locks_in_seconds: Option, } +/// 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)] diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c6a62d9..9756506 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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
starting…
+ if (session.isError) return
{session.error.message}
+ if (session.data.locked) return return ( -
-

blackbeard wallet

- {info.isPending &&

starting…

} - {info.isError &&

{info.error.message}

} - {info.isSuccess &&

v{info.data.version}

} -
+ +
+ +
+ + } /> + } /> + } /> + } /> + } /> + +
+ +
+
) } diff --git a/ui/src/api/events.ts b/ui/src/api/events.ts new file mode 100644 index 0000000..63ca92e --- /dev/null +++ b/ui/src/api/events.ts @@ -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 { + return listen('session:locked', () => handler()) +} + +export function onBalancesUpdated( + handler: (balances: AccountBalance[]) => void, +): Promise { + return listen('balances:updated', (e) => handler(e.payload)) +} + +export function onTxStatus(handler: (status: TxStatusInfo) => void): Promise { + return listen('tx:status', (e) => handler(e.payload)) +} diff --git a/ui/src/api/generated/AccountBalance.ts b/ui/src/api/generated/AccountBalance.ts index ae6eb1a..b4d9ad7 100644 --- a/ui/src/api/generated/AccountBalance.ts +++ b/ui/src/api/generated/AccountBalance.ts @@ -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, }; diff --git a/ui/src/api/generated/ChainStatus.ts b/ui/src/api/generated/ChainStatus.ts index 17c9eb7..031c69d 100644 --- a/ui/src/api/generated/ChainStatus.ts +++ b/ui/src/api/generated/ChainStatus.ts @@ -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. */ diff --git a/ui/src/api/generated/HighSecurityInfo.ts b/ui/src/api/generated/HighSecurityInfo.ts index fa94918..7d5930f 100644 --- a/ui/src/api/generated/HighSecurityInfo.ts +++ b/ui/src/api/generated/HighSecurityInfo.ts @@ -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, }; diff --git a/ui/src/api/generated/PendingReversible.ts b/ui/src/api/generated/PendingReversible.ts index a67bcbc..3c3cda4 100644 --- a/ui/src/api/generated/PendingReversible.ts +++ b/ui/src/api/generated/PendingReversible.ts @@ -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). diff --git a/ui/src/api/generated/PreparedTransferInfo.ts b/ui/src/api/generated/PreparedTransferInfo.ts index ff2f0e6..86c106b 100644 --- a/ui/src/api/generated/PreparedTransferInfo.ts +++ b/ui/src/api/generated/PreparedTransferInfo.ts @@ -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, }; diff --git a/ui/src/api/generated/SessionStatusInfo.ts b/ui/src/api/generated/SessionStatusInfo.ts index 0f8f59c..f62a6f9 100644 --- a/ui/src/api/generated/SessionStatusInfo.ts +++ b/ui/src/api/generated/SessionStatusInfo.ts @@ -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, locks_in_seconds: bigint | null, }; +export type SessionStatusInfo = { locked: boolean, wallet: WalletSummary | null, accounts: Array, locks_in_seconds: number, }; diff --git a/ui/src/api/generated/Settings.ts b/ui/src/api/generated/Settings.ts new file mode 100644 index 0000000..f49e722 --- /dev/null +++ b/ui/src/api/generated/Settings.ts @@ -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, }; diff --git a/ui/src/api/generated/WalletSummary.ts b/ui/src/api/generated/WalletSummary.ts index fbadb5d..ed66399 100644 --- a/ui/src/api/generated/WalletSummary.ts +++ b/ui/src/api/generated/WalletSummary.ts @@ -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, }; diff --git a/ui/src/api/hooks.ts b/ui/src/api/hooks.ts index 5c05e84..4e13051 100644 --- a/ui/src/api/hooks.ts +++ b/ui/src/api/hooks.ts @@ -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]) +} diff --git a/ui/src/api/wallet.ts b/ui/src/api/wallet.ts index da46216..54522b6 100644 --- a/ui/src/api/wallet.ts +++ b/ui/src/api/wallet.ts @@ -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 { return command('reversible_cancel_prepare', { chain, from, txId }) } + +export function settingsGet(): Promise { + return command('settings_get') +} + +export function settingsSet(settings: Settings): Promise { + return command('settings_set', { settings }) +} diff --git a/ui/src/components/ChainCaveat.tsx b/ui/src/components/ChainCaveat.tsx new file mode 100644 index 0000000..c6f10cf --- /dev/null +++ b/ui/src/components/ChainCaveat.tsx @@ -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 ( +

+ {profile.display_name} is a {profile.kind}: its {profile.token_symbol} has no value. +

+ ) +} diff --git a/ui/src/components/LockScreen.tsx b/ui/src/components/LockScreen.tsx new file mode 100644 index 0000000..03b9fee --- /dev/null +++ b/ui/src/components/LockScreen.tsx @@ -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('') + 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 ( +
+
+
+

Unlock

+ blackbeard wallet +
+
+ {wallets.isPending &&

Reading wallets…

} + {wallets.isError &&

{wallets.error.message}

} + {wallets.isSuccess && list.length === 0 && ( +

+ No wallets yet. Creating and importing arrive with the onboarding flow; until then, + place a quantus-cli wallet file in the keystore directory. +

+ )} + {list.length > 0 && ( + <> +
+ + +
+
+ + setPassword(e.target.value)} + /> +
+ {unlock.isError &&

{unlock.error.message}

} +
+ +
+ + )} +
+
+
+ ) +} diff --git a/ui/src/components/NetworkSwitcher.tsx b/ui/src/components/NetworkSwitcher.tsx new file mode 100644 index 0000000..2022cd1 --- /dev/null +++ b/ui/src/components/NetworkSwitcher.tsx @@ -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 ( +
+ + +
+ ) +} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx new file mode 100644 index 0000000..904dda6 --- /dev/null +++ b/ui/src/components/Sidebar.tsx @@ -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 ( + + ) +} diff --git a/ui/src/components/StatusBar.tsx b/ui/src/components/StatusBar.tsx new file mode 100644 index 0000000..481473b --- /dev/null +++ b/ui/src/components/StatusBar.tsx @@ -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 ( +
+ + + {profile?.display_name ?? '…'} + {profile && profile.kind !== 'mainnet' && ( + <> + {' '} + {profile.kind} + + )} + + {s?.connected ? ( + <> + {s.endpoint} + + best #{s.best_number ?? '…'} · final #{s.finalized_number ?? '…'} + + spec {s.spec_version} + + ) : ( + {s?.last_error ?? 'connecting…'} + )} +
+ ) +} diff --git a/ui/src/index.css b/ui/src/index.css index fa6277c..893ebcc 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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; } diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts new file mode 100644 index 0000000..d724abb --- /dev/null +++ b/ui/src/lib/format.ts @@ -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 +} diff --git a/ui/src/lib/theme.ts b/ui/src/lib/theme.ts new file mode 100644 index 0000000..bea44c5 --- /dev/null +++ b/ui/src/lib/theme.ts @@ -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) +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index a3caedf..e88a760 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -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: { diff --git a/ui/src/routes/Accounts.tsx b/ui/src/routes/Accounts.tsx new file mode 100644 index 0000000..76c6923 --- /dev/null +++ b/ui/src/routes/Accounts.tsx @@ -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 ( + <> + +
+
+

Accounts

+ {balances.data?.[0] && ( + + at block {balances.data[0].at_block} + {balances.data[0].finalized_block != null && ( + <> + {' '} + ·{' '} + + {Math.max(0, balances.data[0].at_block - balances.data[0].finalized_block)} + {' '} + from final + + )} + + )} +
+ {balances.isPending &&

Reading balances from the chain…

} + {balances.isError &&

{balances.error.message}

} + {balances.isSuccess && ( + + + + + + + + + + + + {balances.data.map((b, i) => ( + + + + + + + + ))} + +
AddressSchemeSpendableTotalNotes
{b.address}{session.data?.accounts[i]?.scheme ?? ''} + {b.spendable.raw === '0' ? '0' : formatAmount(b.spendable)} {b.symbol} + + {formatAmount(b.total)} {b.symbol} + + {b.high_security ? 'high-security account: transfers are delayed' : ''} +
+ )} +
+ + ) +} diff --git a/ui/src/routes/Placeholder.tsx b/ui/src/routes/Placeholder.tsx new file mode 100644 index 0000000..09e403a --- /dev/null +++ b/ui/src/routes/Placeholder.tsx @@ -0,0 +1,10 @@ +export function Placeholder({ title, issue }: { title: string; issue: number }) { + return ( +
+
+

{title}

+
+

Arrives with issue #{issue}.

+
+ ) +} diff --git a/ui/src/routes/Settings.tsx b/ui/src/routes/Settings.tsx new file mode 100644 index 0000000..53f7904 --- /dev/null +++ b/ui/src/routes/Settings.tsx @@ -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(storedTheme()) + if (!settings.data) return null + const s = settings.data + return ( +
+
+

Settings

+ {info.data && v{info.data.version}} +
+
+
+ + + set.mutate({ + ...s, + idle_lock_seconds: Math.max(1, Number(e.target.value)) * 60, + }) + } + /> +
+
+ +
+
+ + +
+ {set.isError &&

{set.error.message}

} +
+
+ ) +} diff --git a/ui/src/tokens.css b/ui/src/tokens.css new file mode 100644 index 0000000..03049d6 --- /dev/null +++ b/ui/src/tokens.css @@ -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; +}