diff --git a/CHANGELOG.md b/CHANGELOG.md index 063da81..3551ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - A change described without `--execute` starts, on a terminal, with what it would do and the values it would use (`! Not sent: this would send a payment`) and ends with how to send it, instead of an `error:` line. Exit code 6 is unchanged. - Human output shows amounts with grouped digits, every BTC amount in sats (`150,000 sats`, `1.5 sats`) and cents as dollars (`$1,234.56`), and table timestamps relative to now (`3 minutes ago`); single resources show the UTC time with the relative time beside it. With color on, headers and field names are bold and known statuses are colored. JSON is unchanged. - Help headers and usage are Voltage orange where the terminal draws 256 or 24-bit color, and bold elsewhere. `FORCE_COLOR` is honored: `0` or `false` turns color off, other values turn it on when redirected; `--color` and `NO_COLOR` still win. +- A successful `voltage login` on a terminal shows the `voltage` wordmark, a lightning bolt beside the name in Voltage orange, above `✓ Logged in as`. ## 0.2.0 - 2026-10-22 diff --git a/docs/guides/login-and-profiles.md b/docs/guides/login-and-profiles.md index 2e11cb0..cfeeeb0 100644 --- a/docs/guides/login-and-profiles.md +++ b/docs/guides/login-and-profiles.md @@ -2,7 +2,7 @@ ## Log in -`voltage login` prints a verification URL and a code, opens the URL in your browser, and waits for your approval after your usual login and MFA. On a terminal the URL is a clickable link (in terminals that support links) whose text is the whole URL, the code is bold, the wait counts down to when the CLI stops waiting for approval (the code's expiry, or 10 minutes at most), and a closing `✓ Logged in as EMAIL` line names who signed in. Use `--no-browser` on a remote machine and open the URL yourself. The saved login refreshes its tokens automatically; the refresh token rotates. +`voltage login` prints a verification URL and a code, opens the URL in your browser, and waits for your approval after your usual login and MFA. On a terminal the URL is a clickable link (in terminals that support links) whose text is the whole URL, the code is bold, the wait counts down to when the CLI stops waiting for approval (the code's expiry, or 10 minutes at most), and a closing `✓ Logged in as EMAIL` line names who signed in, under the `voltage` wordmark, a yellow lightning bolt beside the name in Voltage orange (left out on a terminal narrower than 40 columns, and with `--quiet`). Use `--no-browser` on a remote machine and open the URL yourself. The saved login refreshes its tokens automatically; the refresh token rotates. ```sh voltage login # saved under your email address diff --git a/src/auth.rs b/src/auth.rs index 23c2446..b053228 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -17,6 +17,7 @@ use crate::{ secret::Secret, style::{Line, Role}, terminal::Terminal, + wordmark, }; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; @@ -743,6 +744,7 @@ fn show_logged_in(terminal: Terminal, outcome: &LoginOutcome) { if !terminal.decorates() { return; } + wordmark::show(terminal); let who = outcome.email.as_deref().unwrap_or(&outcome.account); let mut line = Line::default() .paint(Role::Success, terminal.glyphs().done) diff --git a/src/lib.rs b/src/lib.rs index 20f057e..68ab5d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ mod terminal; mod timeline; mod wallet_currency; mod wallet_shortlist; +mod wordmark; pub(crate) use error::{Error, Result}; pub use startup::run; diff --git a/src/style.rs b/src/style.rs index fe2b191..5bfff46 100644 --- a/src/style.rs +++ b/src/style.rs @@ -126,6 +126,8 @@ pub enum Role { Link, /// A value a person reads or types, such as a device code Emphasis, + /// The wordmark's lightning bolt; decoration, never a status + Bolt, } impl Role { @@ -139,6 +141,7 @@ impl Role { Self::Caution => AnsiColor::Yellow.on_default().bold(), Self::Link => AnsiColor::Blue.on_default().underline(), Self::Emphasis => Style::new().bold(), + Self::Bolt => AnsiColor::Yellow.on_default().bold(), } } } @@ -168,6 +171,13 @@ impl Line { self } + /// Fixed text in the brand color, at the depth the terminal draws. + pub fn brand(mut self, depth: Depth, label: &'static str) -> Self { + let style = brand(depth); + self.0.push_str(&format!("{style}{label}{style:#}")); + self + } + /// A URL that terminals with OSC 8 support make clickable. The visible text is the whole /// URL, so a link cannot hide where it goes; anstream strips the sequence with color off. pub fn link(mut self, url: &str) -> Self { @@ -351,6 +361,7 @@ mod tests { Role::Caution, Role::Link, Role::Emphasis, + Role::Bolt, ] { let painted = paint(role, "error:"); assert!(painted.starts_with('\u{1b}') && painted.ends_with("\u{1b}[0m")); diff --git a/src/wordmark.rs b/src/wordmark.rs new file mode 100644 index 0000000..0331940 --- /dev/null +++ b/src/wordmark.rs @@ -0,0 +1,90 @@ +//! The `voltage` wordmark, shown on a terminal at moments worth marking, such as a first +//! login: a lightning bolt beside the name. ASCII only, so every font draws it; the bolt is +//! yellow and the name is in the brand color where there is one. + +use crate::{ + style::{Depth, Line, Role}, + terminal::{self, Terminal}, +}; + +/// The bolt, padded to one width with the gap before the name, so the name lines up. +#[rustfmt::skip] +const BOLT: [&str; 5] = [ + " __ ", + " / / ", + " / /_ ", + " /_ / ", + " // ", +]; + +/// figlet's `smslant` font. Raw strings keep the backslashes as drawn. +const WORDMARK: [&str; 5] = [ + r" ____", + r" _ _____ / / /____ ____ ____", + r"| |/ / _ \/ / __/ _ `/ _ `/ -_)", + r"|___/\___/_/\__/\_,_/\_, /\__/", + r" /___/", +]; + +/// Columns the bolt and name need; a narrower terminal would wrap them, so they are left out. +const WIDTH: usize = 40; + +/// Show the wordmark on an interactive stderr wide enough for it. `--quiet` hides it. +pub fn show(terminal: Terminal) { + if !terminal.decorates() || !fits(terminal::stderr_width()) { + return; + } + let depth = Depth::detect(|name| std::env::var(name).ok()); + for line in lines(depth) { + terminal.notice_line(line); + } +} + +/// An unknown width is assumed to fit, as a table assumes it is not width-limited. +fn fits(width: Option) -> bool { + width.is_none_or(|width| width >= WIDTH) +} + +fn lines(depth: Depth) -> Vec { + BOLT.iter() + .zip(WORDMARK) + .map(|(bolt, name)| Line::default().paint(Role::Bolt, bolt).brand(depth, name)) + .chain(std::iter::once(Line::default())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_wordmark_is_ascii_and_fits_its_width() { + for (bolt, name) in BOLT.iter().zip(WORDMARK) { + assert!(bolt.is_ascii() && bolt.len() == BOLT[0].len(), "{bolt}"); + assert!(name.is_ascii() && bolt.len() + name.len() < WIDTH, "{name}"); + assert_eq!(name, name.trim_end()); + } + } + + #[test] + fn a_terminal_narrower_than_the_wordmark_leaves_it_out() { + assert!(fits(None)); + assert!(fits(Some(WIDTH))); + assert!(!fits(Some(WIDTH - 1))); + } + + #[test] + fn each_line_is_painted_in_the_brand_color_and_a_blank_line_follows() { + let lines = lines(Depth::Ansi256); + assert_eq!(lines.len(), WORDMARK.len() + 1); + assert_eq!( + lines[1].as_str(), + format!( + "{}\u{1b}[1m\u{1b}[38;5;202m{}\u{1b}[0m", + crate::style::paint(Role::Bolt, BOLT[1]), + WORDMARK[1] + ) + ); + assert_eq!(lines[WORDMARK.len()].as_str(), ""); + } +} diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index ee8bd53..d9f646c 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -6032,12 +6032,22 @@ async fn device_approval_on_a_terminal_links_the_url_and_counts_down() { shown.contains("Waiting for approval in your browser... 0s · expires in "), "{shown}" ); + // The bolt and wordmark, in yellow and the brand color, lead the closing line. + let wordmark = + " /_ / |___/\\___/_/\\__/\\_,_/\\_, /\\__/\r\n // /___/\r\n\r\n"; assert!( shown.contains(&format!( - "✓ Logged in as {LOGIN_EMAIL} (account: {LOGIN_NAME})" + "{wordmark}✓ Logged in as {LOGIN_EMAIL} (account: {LOGIN_NAME})" )), "{shown}" ); + assert!( + run.shown.contains( + "\u{1b}[1m\u{1b}[33m __ \u{1b}[0m\u{1b}[1m\u{1b}[38;5;202m ____\u{1b}[0m" + ), + "{:?}", + run.shown + ); server.verify().await; }