diff --git a/Cargo.lock b/Cargo.lock index 96bf4ab..edfee43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3682,9 +3682,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] diff --git a/crates/deckard-app/src/money.rs b/crates/deckard-app/src/money.rs index b64f34b..87aa96c 100644 --- a/crates/deckard-app/src/money.rs +++ b/crates/deckard-app/src/money.rs @@ -5,17 +5,27 @@ //! only* (`text.muted`) — never by a size step, which would produce the //! superscript look DESIGN rejects. The integer carries `text.primary`. //! -//! Two entry points wrap the single canonical formatter +//! Three entry points wrap the single canonical formatter //! `deckard_core::format_amount(raw, decimals, max_frac)`: //! - [`money`] — an asset amount with an optional trailing ticker (`1,934.5 ETH`). //! - [`usd`] — a USD figure carrying the `$` prefix; zero renders `$0`, never //! `$0.0` (the `$` discipline + the zero rule). +//! - [`money_cell`] — a decimal-point-aligned ledger cell (integer right-aligned to a +//! fixed seam, `.decimals` left-aligned after it) so a holdings column's points line up. -use gpui::{div, prelude::FluentBuilder, Hsla, IntoElement, ParentElement, SharedString, Styled}; +use gpui::{ + div, prelude::FluentBuilder, px, Hsla, IntoElement, ParentElement, Pixels, SharedString, Styled, +}; use gpui_component::h_flex; use deckard_core::U256; +/// The fixed width of a ledger money cell's fractional sub-column — it holds `.decimals` (up to the +/// four-digit hero precision) left-aligned. The integer sub-column takes the rest of the cell and is +/// right-aligned, so the decimal seam sits at a constant `x` across every row in a column and the +/// points line up (the E4 "decimals aligned on the point" rule, #184). Sized to clear `.0000`. +const FRAC_SEAM_W: Pixels = px(52.0); + /// The fixed-length privacy mask: **always six bullets**, never `real.len()`, so a /// masked figure leaks neither its value nor its digit count (the magnitude-safe rule, /// per MetaMask's `SensitiveText`). One glyph for every money surface. @@ -91,6 +101,53 @@ pub fn usd( spans(format!("${int_part}"), frac, None, mono, primary, dim) } +/// A **decimal-point-aligned** money cell for a holdings / ledger column (E4, #184). The integer +/// part is **right-aligned** up to a fixed seam and the `.decimals` are **left-aligned** after it, so +/// the decimal points line up down a column no matter how many fractional digits each row carries. +/// Dimming stays color-only (the decimals in `dim`), size held flat — never a superscript step. When +/// `masked`, renders the fixed [`MASK_BULLETS`] right-aligned to the seam (no value, no decimals). +/// +/// The caller sets the column's width (this fills it, `w_full`); the seam is at `col_w - +/// FRAC_SEAM_W`, identical for every row, which is what makes the points align. +pub fn money_cell( + raw: U256, + decimals: u8, + max_frac: usize, + masked: bool, + mono: SharedString, + primary: Hsla, + dim: Hsla, +) -> impl IntoElement { + let (int_part, frac, int_color) = if masked { + (MASK_BULLETS.to_string(), String::new(), dim) + } else { + let s = deckard_core::format_amount(raw, decimals, max_frac); + let (int_part, frac) = split_amount(&s); + (int_part, frac, primary) + }; + + h_flex() + .w_full() + .font_family(mono) + // Integer: fills the row up to the seam, right-aligned so its ones-digit meets the point. + .child( + h_flex() + .flex_1() + .min_w_0() + .justify_end() + .child(div().flex_shrink_0().text_color(int_color).child(int_part)), + ) + // Fractional seam: a fixed-width, left-aligned sub-column so the point starts at a constant x. + .child( + h_flex() + .w(FRAC_SEAM_W) + .flex_shrink_0() + .when(!frac.is_empty(), |e| { + e.child(div().text_color(dim).child(format!(".{frac}"))) + }), + ) +} + /// Split a formatted amount (`"1,934.5"`) into its integer and (possibly empty) /// fractional parts. `format_amount` already strips trailing zeros, so the frac /// is absent for whole numbers (zero renders `"0"` → `("0", "")`). diff --git a/crates/deckard-app/src/shell_rail.rs b/crates/deckard-app/src/shell_rail.rs index 644284e..ab050fa 100644 --- a/crates/deckard-app/src/shell_rail.rs +++ b/crates/deckard-app/src/shell_rail.rs @@ -92,6 +92,9 @@ impl Shell { .w_full() .gap_2() .child(kv("Balance", KvValue::Mono(&balance))) + // Value: HONEST "—". The app has no price feed, so the rail's fiat row is an explicit + // dash — never a fabricated number (E4, #184; DESIGN §Trust), mirroring the hero meta. + .child(kv("Value", KvValue::Sans("—"))) .child(kv("Synced", KvValue::Sans(&synced))) .child(kv("Status", status_kv)) .child(kv("Network", KvValue::Sans(network))) @@ -126,19 +129,26 @@ impl Shell { .child(kv("Per-transaction", per_tx_val)) .into_any_element(); col = col.child(meta_section(Some("Agent caps"), caps, theme)); - - // The golden ref's trailing composition metasec. Only "Agents" is engine-backed today — - // the app models one agent, armed unless a STOP revoked it — so the "Connections" count - // is omitted (not invented) until the browser bridge lands (ADR-0001 / #44). - let agents = if p.revoked { "1 stopped" } else { "1 active" }; - let summary = v_flex() - .w_full() - .gap_2() - .child(kv("Agents", KvValue::Sans(agents))) - .into_any_element(); - col = col.child(meta_section(None, summary, theme)); } + // The golden ref's trailing composition metasec — ALWAYS present (independent of whether the + // policy has landed). Connections: the reserved/empty state — the browser bridge (dapp + // connections) is deferred (ADR-0001 / #44), so we render the honest empty "none", NEVER a + // fabricated count. Agents: the app models one agent on this wallet; its state follows the + // live policy (idle until it lands, active unless a STOP revoked it). + let agents = match self.agent_policy.as_ref() { + Some(p) if p.revoked => "1 stopped", + Some(_) => "1 active", + None => "idle", + }; + let summary = v_flex() + .w_full() + .gap_2() + .child(kv("Connections", KvValue::Sans("none"))) + .child(kv("Agents", KvValue::Sans(agents))) + .into_any_element(); + col = col.child(meta_section(None, summary, theme)); + col.into_any_element() } diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs index ec8d235..c43d0da 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -15,7 +15,7 @@ use gpui_component::{ use deckard_core::{tokens_for, U256}; -use crate::money::money; +use crate::money::{money, money_cell}; use crate::shell::{Shell, Surface}; use crate::theme; use crate::widgets::{ @@ -33,6 +33,64 @@ struct Holding { max_frac: usize, } +// The holdings ledger's three right-hand numeric columns (DESIGN §Holdings table). Fixed widths are +// shared by the header and every row so the columns can't drift; the `Balance`/`Value` cells align +// on the decimal point via `money::money_cell`. Layout widths (not type sizes) — outside the token +// scale, like the rail/gauge widths elsewhere. Sized so the integer side of a cell (`width - +// FRAC_SEAM_W`) clears ~10 mono chars — every realistic curated-token balance. We deliberately do NOT +// clip the cell: an absurdly-large integer overflows into adjacent whitespace (cosmetic, the decimal +// seam stays fixed so alignment holds) rather than getting its leading digits hidden — never lie +// about how much money there is (E4 §honesty). +const HOLD_BALANCE_W: gpui::Pixels = px(140.0); +const HOLD_DELTA_W: gpui::Pixels = px(60.0); +const HOLD_VALUE_W: gpui::Pixels = px(140.0); + +/// The wallet-home hero meta line's verification claim (E4, #184) — TRUST-CRITICAL honesty +/// (DESIGN §Trust rule 9: "Never fake verified"). The label `verified on mainnet` is asserted ONLY +/// when a real Helios-verified read backs it, and the chain registry only ever grants the verified +/// tier on chain 1 — so every other state (a testnet/fork read, an unsynced/failed read, or no read +/// yet) reads the loud `unverified`. Pure + `pub(crate)` so the mapping is unit-testable and the home +/// and (future) rail can't drift on what counts as verified. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum VerifiedClaim { + /// A Helios-consensus-verified mainnet read — the ONLY verified state. Rendered `success`. + Mainnet, + /// Still cryptographically verified but off the happy path (EL failover / community checkpoint); + /// a downgrade, so it's the loud `warn`, never rendered quiet. + Degraded, + /// No verified read is possible: a testnet/fork, an unsynced/stale/failed read, or none yet. The + /// loud `warn` `unverified` — we never fake a verified look for a read we cannot prove. + Unverified, +} + +impl VerifiedClaim { + /// Map the live `(chain, read status)` to an honest claim. A `Verified` read is only trusted as + /// `Mainnet` when the chain actually IS mainnet (defence in depth: the registry already pins the + /// verified tier to chain 1, so a `Verified` off chain 1 is treated as `Unverified`, never faked). + pub(crate) fn from_read(chain_id: u64, status: Option<&deckard_core::ReadStatus>) -> Self { + let mainnet = deckard_core::verification(chain_id) == deckard_core::Verification::Mainnet; + match status { + Some(deckard_core::ReadStatus::Verified) if mainnet => Self::Mainnet, + Some(deckard_core::ReadStatus::Degraded { .. }) if mainnet => Self::Degraded, + _ => Self::Unverified, + } + } + + /// The honest label for the hero meta line. + pub(crate) fn label(self) -> &'static str { + match self { + Self::Mainnet => "verified on mainnet", + Self::Degraded => "verified · degraded", + Self::Unverified => "unverified", + } + } + + /// Only a fully-verified mainnet read reads as the `success` signal; every downgrade is `warn`. + pub(crate) fn is_verified(self) -> bool { + matches!(self, Self::Mainnet) + } +} + /// The agent's per-transaction Send cap as an HONEST display string (DESIGN §Trust: the UI must /// never claim a fence the engine doesn't set). `per_tx_cap_for` returns `None` in two opposite /// cases, so a bare `unwrap_or(0)` would print a false "0 ETH": no Send rule at all (send is @@ -226,6 +284,9 @@ impl Shell { h_flex() .w_full() .items_center() + // Left-anchored, never centered (E4, #184; DESIGN §wallet home): the + // action cluster reads from the left edge under the hero. + .justify_start() .gap_2p5() // Shield (the privacy hero) is the ONE neutral primary CTA. Send / // Receive / Swap follow as a ghost cluster set off by a thin vertical @@ -383,6 +444,8 @@ impl Shell { let fg = theme.foreground; let muted = theme.muted_foreground; let border = theme.border; + let success = theme.success; + let warn = theme.warning; let mono: SharedString = theme.mono_font_family.clone(); let masked = self.mask; let is_dark = theme.is_dark(); @@ -471,18 +534,46 @@ impl Shell { ), }; - // Label the hero honestly: a real Total once private is known, otherwise public-only - // (so the big figure is never read as "public + 0" while the private side syncs). + // Label the hero honestly only when the big figure is public-only (private still syncing) — + // so it's never read as "public + 0". Once private is known the figure IS the Total, now + // conveyed by the meta line + the Private/Public legend below, so no redundant "Total" caption. let caption = match (private_wei, snap.is_some()) { - (Some(_), _) => "Total", + (Some(_), _) => "", (None, true) => "Public · private balance still syncing", (None, false) => "", }; + // The hero meta line (E4, #184; DESIGN §wallet home): `$… · synced … · verified on mainnet`. + // HONEST fallbacks (DESIGN §Trust): the USD slot is an explicit `—` because the app has NO + // price feed (never a fabricated fiat number), and the verification claim is `verified on + // mainnet` ONLY for a real verified read — a fork/testnet or unsynced read reads the loud + // `unverified`. `synced` mirrors the rail's honest `block N` / `syncing…`. + let claim = VerifiedClaim::from_read(self.chain_id(), self.read_status.as_ref()); + let synced = match self.synced_block { + Some(b) => format!("synced block {b}"), + None => "syncing…".to_string(), + }; + let sep = || div().text_color(muted).child("·"); + let meta = h_flex() + .w_full() + .items_center() + .gap_2() + .text_xs() + .child(div().font_family(mono.clone()).text_color(muted).child("—")) + .child(sep()) + .child(div().text_color(muted).child(synced)) + .child(sep()) + .child( + div() + .text_color(if claim.is_verified() { success } else { warn }) + .child(claim.label()), + ); + v_flex() .w_full() .gap_3() .child(hero) + .child(meta) .children((!caption.is_empty()).then(|| div().text_xs().text_color(muted).child(caption))) .child(bar) .child( @@ -527,6 +618,7 @@ impl Shell { ) -> impl IntoElement { let theme = cx.theme(); let muted = theme.muted_foreground; + let border = theme.border; let mono: SharedString = theme.mono_font_family.clone(); let masked = self.mask; @@ -573,6 +665,44 @@ impl Shell { // The ledger heading (DESIGN §Holdings table): a tiny muted section label, not a // card title. Grouping is whitespace + the per-row hairline below. col = col.child(div().pb_1().child(section_label("Holdings", muted))); + // Column headers — Asset · Balance · 24h · $ Value (E4, #184). Aligned to the row columns via + // the shared `HOLD_*_W` widths + identical `px_4`/`gap_2`, so the header can't drift off its + // column. Numeric labels are right-aligned over their columns. + col = col.child( + h_flex() + .w_full() + .items_center() + .gap_2() + .px_4() + .pb_1() + // The golden ref's `.lhead` carries a bottom hairline setting the header off the rows. + .border_b_1() + .border_color(border) + .text_xs() + .text_color(muted) + .child(div().flex_1().min_w_0().child("Asset")) + .child( + h_flex() + .w(HOLD_BALANCE_W) + .flex_shrink_0() + .justify_end() + .child("Balance"), + ) + .child( + h_flex() + .w(HOLD_DELTA_W) + .flex_shrink_0() + .justify_end() + .child("24h"), + ) + .child( + h_flex() + .w(HOLD_VALUE_W) + .flex_shrink_0() + .justify_end() + .child("Value"), + ), + ); for h in holdings { col = col.child(render_row( theme.foreground, @@ -590,23 +720,20 @@ impl Shell { mono.clone(), )); } - if !has_tokens { - col = col.child( - div() - .text_xs() - .text_color(muted) - .pt_1() - .child("Only listed tokens are shown. Lesser-known tokens may be missing."), - ); + let listed_note = if !has_tokens { + "Only listed tokens are shown. Lesser-known tokens may be missing." } else { - col = col.child( - div() - .text_xs() - .text_color(muted) - .pt_1() - .child("Listed tokens only."), - ); - } + "Listed tokens only." + }; + col = col.child(div().text_xs().text_color(muted).pt_1().child(listed_note)); + // Honest footnote for the empty 24h / $ Value columns: the app has no price feed, so those + // columns show an explicit — rather than a fabricated fiat number (E4 §honesty, DESIGN §Trust). + col = col.child( + div() + .text_xs() + .text_color(muted) + .child("24h change and $ value need a price feed — not wired yet."), + ); col.into_any_element() } } @@ -766,20 +893,24 @@ fn render_row( h_flex() .w_full() .items_center() - .justify_between() + .gap_2() .px_4() .py_3() // DESIGN §Holdings table: tight rows, hairline row separators only — // no per-row card frame, no fill. .border_b_1() .border_color(border) + // Asset — the token mark + name/ticker, growing to fill the row's left. .child( h_flex() + .flex_1() + .min_w_0() .items_center() .gap_3() .child( div() .size(px(34.0)) + .flex_shrink_0() // DESIGN §Radii: a desaturated token SQUARE (6px), not a // round identicon (round is reserved for the human principal). .rounded(crate::tokens::RADIUS_ROW) @@ -797,6 +928,7 @@ fn render_row( ) .child( v_flex() + .min_w_0() .gap_0p5() .child( div() @@ -808,9 +940,40 @@ fn render_row( .child(div().text_xs().text_color(muted).child(symbol)), ), ) - .child(div().text_sm().child(money( - raw, decimals, max_frac, None, masked, mono, fg, muted, - ))) + // Balance — mono, decimal-point-aligned across rows (E4, #184). + .child( + div() + .w(HOLD_BALANCE_W) + .flex_shrink_0() + .text_sm() + .child(money_cell( + raw, + decimals, + max_frac, + masked, + mono.clone(), + fg, + muted, + )), + ) + // 24h change — HONEST "—": the app keeps no price history, so it never fabricates a delta. + .child( + h_flex() + .w(HOLD_DELTA_W) + .flex_shrink_0() + .justify_end() + .text_xs() + .child(div().font_family(mono.clone()).text_color(muted).child("—")), + ) + // $ Value — HONEST "—": no price feed, so never a fabricated fiat number (E4 §honesty). + .child( + h_flex() + .w(HOLD_VALUE_W) + .flex_shrink_0() + .justify_end() + .text_sm() + .child(div().font_family(mono).text_color(muted).child("—")), + ) } #[cfg(test)] @@ -888,4 +1051,60 @@ mod tests { assert_eq!(get("Recipients"), "1 allowed"); assert_eq!(get("STOP brake"), "engaged, unlock to re-arm"); } + + use deckard_core::ReadStatus; + + const MAINNET: u64 = 1; + const SEPOLIA: u64 = 11155111; + const ANVIL: u64 = 31337; + + /// TRUST-CRITICAL (DESIGN §Trust rule 9): the hero meta line claims "verified on mainnet" ONLY + /// for a real Helios-verified read on chain 1, and reads it as the `success` signal. + #[test] + fn verified_claim_only_a_verified_mainnet_read_is_verified() { + let c = VerifiedClaim::from_read(MAINNET, Some(&ReadStatus::Verified)); + assert_eq!(c, VerifiedClaim::Mainnet); + assert_eq!(c.label(), "verified on mainnet"); + assert!(c.is_verified()); + } + + /// A downgrade is never rendered quiet: a still-verified-but-degraded read is loud, NOT the + /// `success` "verified on mainnet". + #[test] + fn verified_claim_degraded_is_a_loud_downgrade() { + let c = VerifiedClaim::from_read(MAINNET, Some(&ReadStatus::degraded("EL failover"))); + assert_eq!(c, VerifiedClaim::Degraded); + assert_eq!(c.label(), "verified · degraded"); + assert!(!c.is_verified()); + } + + /// An unsynced/stale/failed read and a not-yet-synced read both read the loud "unverified" — + /// never a faked verified look. + #[test] + fn verified_claim_unsynced_and_none_are_unverified() { + for status in [Some(ReadStatus::unsynced("head stale")), None] { + let c = VerifiedClaim::from_read(MAINNET, status.as_ref()); + assert_eq!(c, VerifiedClaim::Unverified); + assert_eq!(c.label(), "unverified"); + assert!(!c.is_verified()); + } + } + + /// HONESTY off mainnet: a fork/testnet is never "verified on mainnet", even if a read somehow + /// reports `Verified` — the claim is pinned to chain 1 (defence in depth over the registry). + #[test] + fn verified_claim_off_mainnet_is_never_verified() { + for chain in [SEPOLIA, ANVIL] { + // Even a (spurious) Verified read off chain 1 must not fake the mainnet claim. + assert_eq!( + VerifiedClaim::from_read(chain, Some(&ReadStatus::Verified)), + VerifiedClaim::Unverified, + ); + // The everyday fork case: no verified read at all. + assert_eq!( + VerifiedClaim::from_read(chain, None), + VerifiedClaim::Unverified, + ); + } + } }