diff --git a/README.md b/README.md index c6a467e..188014b 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,17 @@ Activities and pin it to the dash. Remove everything with - [x] **PR1** — core CPU engine (mnemonic, match modes, derivation) + tests - [x] **PR2** — egui GUI (search, live progress, accumulating results) - [x] **PR5** — packaging (local `.desktop` install + icon) +- [x] **Design** — egui theme (Inter / JetBrains Mono, amber accent, cards, match highlight) - [ ] **PR3** — security / UX (masked secrets, clipboard auto-clear, export, offline badge) - [ ] **PR4** — optional GPU engine (external `profanity2` + safe key combine) - [ ] *(later)* RPM / Flatpak distribution +## Fonts + +The UI bundles [Inter](https://github.com/rsms/inter) and +[JetBrains Mono](https://github.com/JetBrains/JetBrainsMono), both under the SIL +Open Font License 1.1 (see `crates/gui/assets/fonts/OFL-*.txt`). + ## License -[MIT](LICENSE) +Project code: [MIT](LICENSE). Bundled fonts: SIL OFL 1.1 (see above). diff --git a/crates/gui/assets/fonts/Inter-Regular.ttf b/crates/gui/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/crates/gui/assets/fonts/Inter-Regular.ttf differ diff --git a/crates/gui/assets/fonts/Inter-SemiBold.ttf b/crates/gui/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/crates/gui/assets/fonts/Inter-SemiBold.ttf differ diff --git a/crates/gui/assets/fonts/JetBrainsMono-Regular.ttf b/crates/gui/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..711830e Binary files /dev/null and b/crates/gui/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/crates/gui/assets/fonts/OFL-Inter.txt b/crates/gui/assets/fonts/OFL-Inter.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/crates/gui/assets/fonts/OFL-Inter.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/gui/assets/fonts/OFL-JetBrainsMono.txt b/crates/gui/assets/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/crates/gui/assets/fonts/OFL-JetBrainsMono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/gui/src/main.rs b/crates/gui/src/main.rs index 9903dc9..5796409 100644 --- a/crates/gui/src/main.rs +++ b/crates/gui/src/main.rs @@ -4,33 +4,41 @@ //! a channel so the UI never blocks. Secret material (mnemonic, private key) is //! hidden until the user reveals it, and nothing is written to disk. +mod theme; + use std::collections::HashSet; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread::JoinHandle; -use std::time::Duration; +use std::time::{Duration, Instant}; use crossbeam_channel::{Receiver, TryRecvError}; -use eframe::egui; +use eframe::egui::{self, FontFamily, RichText}; use hexforge_core::{ search, FoundWallet, MatchMode, Progress, SearchConfig, SearchError, Target, DEFAULT_DERIVATION_PATH, }; +use theme::color; + +/// How long the "Скопировано ✓" confirmation stays on a copy button. +const COPIED_FLASH: Duration = Duration::from_millis(1200); + fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() // app_id must match StartupWMClass in the .desktop so the window // groups under the launcher icon (Wayland/X11). .with_app_id("hexforge") - .with_inner_size([560.0, 440.0]), + .with_inner_size([560.0, 460.0]) + .with_min_inner_size([480.0, 420.0]), ..Default::default() }; eframe::run_native( "hexforge", options, Box::new(|cc| { - cc.egui_ctx.set_visuals(egui::Visuals::dark()); + theme::apply(&cc.egui_ctx); Ok(Box::new(HexforgeApp::default())) }), ) @@ -65,6 +73,7 @@ struct HexforgeApp { progress: Option, error: Option, reveal: HashSet, + copied: Option<(usize, Instant)>, worker: Option, } @@ -78,6 +87,7 @@ impl Default for HexforgeApp { progress: None, error: None, reveal: HashSet::new(), + copied: None, worker: None, } } @@ -185,6 +195,27 @@ impl HexforgeApp { } } + fn copied_flash_active(&self) -> bool { + self.copied + .is_some_and(|(_, at)| at.elapsed() < COPIED_FLASH) + } + + fn ui_brand(ui: &mut egui::Ui) { + ui.horizontal(|ui| { + theme::paint_hexmark(ui, 26.0); + ui.add_space(2.0); + ui.heading(RichText::new("hexforge").color(color::TXT)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new("vanity eth") + .size(11.0) + .family(FontFamily::Monospace) + .color(color::TXT_FAINT), + ); + }); + }); + } + fn ui_controls(&mut self, ui: &mut egui::Ui) { let validation = validation_message(&self.word); // Empty input shows no error but must not enable the search. @@ -192,40 +223,47 @@ impl HexforgeApp { let running = self.state == RunState::Running; ui.horizontal(|ui| { - ui.label("Слово:"); + ui.label(RichText::new("Слово:").color(color::TXT_MUTED)); ui.add_enabled( !running, - egui::TextEdit::singleline(&mut self.word).hint_text("deadbeef"), + egui::TextEdit::singleline(&mut self.word) + .hint_text("deadbeef") + .font(egui::TextStyle::Monospace) + .desired_width(f32::INFINITY), ); }); if let Some(message) = &validation { - ui.colored_label(egui::Color32::from_rgb(220, 90, 90), message); + ui.label(RichText::new(message).size(11.0).color(color::DANGER)); } ui.horizontal(|ui| { - ui.label("Где:"); + ui.label(RichText::new("Где:").color(color::TXT_MUTED)); ui.radio_value(&mut self.mode, MatchMode::Prefix, "в начале"); ui.radio_value(&mut self.mode, MatchMode::Anywhere, "везде"); ui.radio_value(&mut self.mode, MatchMode::Suffix, "в конце"); }); - let has_results = !self.found.is_empty(); ui.horizontal(|ui| { - if ui - .add_enabled(can_start && !running, egui::Button::new("Искать")) - .clicked() - { + let primary = if can_start && !running { + egui::Button::new(RichText::new("Искать").color(color::ON_ACCENT)) + .fill(color::ACCENT) + } else { + egui::Button::new(RichText::new("Искать").color(color::TXT_FAINT)) + }; + if ui.add_enabled(can_start && !running, primary).clicked() { self.start(); } if ui.add_enabled(running, egui::Button::new("Стоп")).clicked() { self.stop(); } + let has_results = !self.found.is_empty(); if ui .add_enabled(has_results && !running, egui::Button::new("Очистить")) .clicked() { self.found.clear(); self.reveal.clear(); + self.copied = None; } }); } @@ -233,26 +271,41 @@ impl HexforgeApp { fn ui_status(&self, ui: &mut egui::Ui) { match self.state { RunState::Idle => { - ui.label("Введите hex-слово и нажмите «Искать»."); + ui.horizontal(|ui| { + status_dot(ui, color::TXT_FAINT); + ui.label( + RichText::new("Введите hex-слово и нажмите «Искать».") + .color(color::TXT_MUTED), + ); + }); } RunState::Running => { ui.horizontal(|ui| { - ui.spinner(); + ui.add(egui::Spinner::new().color(color::ACCENT).size(14.0)); match &self.progress { - Some(progress) => ui.label(format_progress(progress)), - None => ui.label("Идёт поиск… (разогрев)"), - } + Some(progress) => ui.label( + RichText::new(format_progress(progress)) + .family(FontFamily::Monospace) + .color(color::TXT), + ), + None => ui + .label(RichText::new("Идёт поиск… (разогрев)").color(color::TXT_MUTED)), + }; }); } RunState::Done => { - if let Some(error) = &self.error { - ui.colored_label( - egui::Color32::from_rgb(220, 90, 90), - format!("Ошибка: {error}"), - ); - } else { - ui.label(format!("Готово · найдено {}", self.found.len())); - } + ui.horizontal(|ui| { + if let Some(error) = &self.error { + status_dot(ui, color::DANGER); + ui.label(RichText::new(format!("Ошибка: {error}")).color(color::DANGER)); + } else { + status_dot(ui, color::SUCCESS); + ui.label( + RichText::new(format!("Готово · найдено {}", self.found.len())) + .color(color::TXT_MUTED), + ); + } + }); } } } @@ -260,59 +313,84 @@ impl HexforgeApp { fn ui_results(&mut self, ui: &mut egui::Ui) { if self.found.is_empty() { if self.state == RunState::Running { - ui.label("Пока ничего не найдено — ищем…"); + ui.label(RichText::new("Пока ничего не найдено — ищем…").color(color::TXT_MUTED)); } return; } - ui.label(format!("Найдено: {}", self.found.len())); + + ui.horizontal(|ui| { + ui.label( + RichText::new("Найдено: ") + .family(theme::strong_family()) + .color(color::TXT), + ); + ui.label( + RichText::new(self.found.len().to_string()) + .family(theme::strong_family()) + .color(color::ACCENT), + ); + }); + ui.add_space(2.0); + let mut toggle: Option = None; + let mut copied_now: Option = None; egui::ScrollArea::vertical() .auto_shrink(false) .show(ui, |ui| { for (index, wallet) in self.found.iter().enumerate() { let revealed = self.reveal.contains(&index); - ui.group(|ui| { - ui.horizontal(|ui| { - ui.monospace(wallet.address.as_str()); - if ui.button("Копировать адрес").clicked() { - ui.ctx().copy_text(wallet.address.clone()); - } - let label = if revealed { - "Скрыть" - } else { - "Показать секрет" - }; - if ui.button(label).clicked() { - toggle = Some(index); + let is_copied = self + .copied + .is_some_and(|(ci, at)| ci == index && at.elapsed() < COPIED_FLASH); + egui::Frame::default() + .fill(color::CARD_BG) + .stroke(egui::Stroke::new(1.0, color::BORDER)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::same(12)) + .shadow(theme::card_shadow()) + .show(ui, |ui| { + ui.label(theme::address_layout(&wallet.address, &wallet.target_word)); + ui.add_space(2.0); + ui.horizontal(|ui| { + let copy_label = if is_copied { + RichText::new("Скопировано ✓") + .size(12.0) + .color(color::SUCCESS) + } else { + RichText::new("Копировать адрес").size(12.0) + }; + if ui.button(copy_label).clicked() { + ui.ctx().copy_text(wallet.address.clone()); + copied_now = Some(index); + } + let reveal_label = if revealed { + "Скрыть" + } else { + "Показать секрет" + }; + if ui.button(RichText::new(reveal_label).size(12.0)).clicked() { + toggle = Some(index); + } + }); + if revealed { + // NOTE: rendering a secret hands its text to egui's + // galley cache (not zeroized). Inherent to showing a + // secret in any GUI; reveal is an explicit action. + ui.add_space(2.0); + secret_block(ui, wallet); } }); - if revealed { - // NOTE: rendering a secret hands its text to egui's galley - // cache (font layout), which is not zeroized. This is - // inherent to displaying secrets in any GUI; reveal is an - // explicit, transient user action. - ui.add( - egui::Label::new( - egui::RichText::new(wallet.mnemonic.as_str()).monospace(), - ) - .selectable(true), - ); - let private_key = wallet.private_key_hex(); - ui.add( - egui::Label::new( - egui::RichText::new(private_key.as_str()).monospace(), - ) - .selectable(true), - ); - } - }); } }); + if let Some(index) = toggle { if !self.reveal.remove(&index) { self.reveal.insert(index); } } + if let Some(index) = copied_now { + self.copied = Some((index, Instant::now())); + } } } @@ -320,24 +398,31 @@ impl eframe::App for HexforgeApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.drain_messages(); - // Fixed controls on top, fixed note at the bottom, scrolling results in - // the middle — the list scrolls no matter how many wallets accumulate. egui::Panel::top("controls").show_inside(ui, |ui| { - ui.heading("hexforge"); ui.add_space(4.0); + Self::ui_brand(ui); + ui.add_space(6.0); self.ui_controls(ui); + ui.add_space(2.0); ui.separator(); self.ui_status(ui); + ui.add_space(2.0); }); egui::Panel::bottom("footer").show_inside(ui, |ui| { ui.separator(); - ui.small("🔒 Offline · ключи не сохраняются на диск · выпиши seed на бумагу"); + ui.add_space(2.0); + ui.label( + RichText::new("🔒 Offline · ключи не сохраняются на диск · выпиши seed на бумагу") + .size(11.0) + .color(color::TXT_FAINT), + ); + ui.add_space(2.0); }); egui::CentralPanel::default().show_inside(ui, |ui| { self.ui_results(ui); }); - if self.state == RunState::Running { + if self.state == RunState::Running || self.copied_flash_active() { ui.ctx().request_repaint(); } } @@ -350,6 +435,51 @@ impl eframe::App for HexforgeApp { } } +/// Paints a small filled status dot. +fn status_dot(ui: &mut egui::Ui, fill: egui::Color32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(9.0, 9.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.center(), 3.5, fill); +} + +/// Renders the revealed secret block (mnemonic + private key) in an inset frame. +fn secret_block(ui: &mut egui::Ui, wallet: &FoundWallet) { + egui::Frame::default() + .fill(color::INSET_BG) + .stroke(egui::Stroke::new(1.0, color::BORDER_SOFT)) + .corner_radius(egui::CornerRadius::same(6)) + .inner_margin(egui::Margin::same(12)) + .show(ui, |ui| { + ui.label( + RichText::new("SEED-ФРАЗА") + .size(11.0) + .color(color::TXT_MUTED), + ); + ui.add( + egui::Label::new( + RichText::new(wallet.mnemonic.as_str()) + .family(FontFamily::Monospace) + .color(color::MONO_TXT), + ) + .selectable(true), + ); + ui.add_space(6.0); + ui.label( + RichText::new("ПРИВАТНЫЙ КЛЮЧ") + .size(11.0) + .color(color::TXT_MUTED), + ); + let private_key = wallet.private_key_hex(); + ui.add( + egui::Label::new( + RichText::new(private_key.as_str()) + .family(FontFamily::Monospace) + .color(color::TXT), + ) + .selectable(true), + ); + }); +} + /// Generated keys per second, guarding against a zero elapsed time. fn rate_per_sec(attempts: u64, elapsed: Duration) -> u64 { // checked_div returns None when elapsed is < 1s; fall back to the raw count. diff --git a/crates/gui/src/theme.rs b/crates/gui/src/theme.rs new file mode 100644 index 0000000..9e4c7c1 --- /dev/null +++ b/crates/gui/src/theme.rs @@ -0,0 +1,218 @@ +//! Visual theme ported from the design mockup (`docs/design-brief.md`). +//! +//! Warm near-black charcoal + amber accent, Inter (UI) / JetBrains Mono +//! (addresses). Provides the palette, font installation, the egui [`Style`], +//! and two shared painters (the hex mark and the highlighted-address layout). + +use std::sync::Arc; + +use eframe::egui::{ + self, text::LayoutJob, text::TextFormat, Color32, CornerRadius, FontData, FontDefinitions, + FontFamily, FontId, Stroke, TextStyle, +}; + +/// Color tokens (1:1 with the mockup's CSS `:root`). +pub mod color { + use eframe::egui::Color32; + + pub const WINDOW_BG: Color32 = Color32::from_rgb(0x14, 0x12, 0x0d); + pub const CARD_BG: Color32 = Color32::from_rgb(0x1e, 0x1a, 0x13); + pub const INPUT_BG: Color32 = Color32::from_rgb(0x10, 0x0e, 0x09); + pub const INSET_BG: Color32 = Color32::from_rgb(0x10, 0x0d, 0x08); + pub const SEC_BG: Color32 = Color32::from_rgb(0x22, 0x1e, 0x16); + pub const SEC_HOVER: Color32 = Color32::from_rgb(0x2a, 0x25, 0x19); + pub const SEC_PRESS: Color32 = Color32::from_rgb(0x1a, 0x16, 0x0f); + pub const BORDER: Color32 = Color32::from_rgb(0x2e, 0x28, 0x1d); + pub const BORDER_SOFT: Color32 = Color32::from_rgb(0x24, 0x1f, 0x17); + pub const TXT: Color32 = Color32::from_rgb(0xec, 0xe6, 0xda); + pub const TXT_MUTED: Color32 = Color32::from_rgb(0x96, 0x8f, 0x82); + pub const TXT_FAINT: Color32 = Color32::from_rgb(0x6b, 0x65, 0x57); + pub const ACCENT: Color32 = Color32::from_rgb(0xf5, 0xa6, 0x23); + pub const ON_ACCENT: Color32 = Color32::from_rgb(0x1a, 0x15, 0x0b); + pub const DANGER: Color32 = Color32::from_rgb(0xe8, 0x60, 0x4c); + pub const SUCCESS: Color32 = Color32::from_rgb(0x62, 0xc0, 0x7e); + pub const MONO_TXT: Color32 = Color32::from_rgb(0xd2, 0xcb, 0xbd); +} + +/// Name of the SemiBold proportional family (titles / headers / buttons). +const STRONG: &str = "strong"; + +/// Installs fonts and the theme [`Style`] onto the context. Call once at start. +pub fn apply(ctx: &egui::Context) { + install_fonts(ctx); + ctx.set_global_style(build_style()); +} + +/// The SemiBold proportional family handle (for headings / button text). +pub fn strong_family() -> FontFamily { + FontFamily::Name(STRONG.into()) +} + +fn install_fonts(ctx: &egui::Context) { + let mut fonts = FontDefinitions::default(); + + fonts.font_data.insert( + "inter".to_owned(), + Arc::new(FontData::from_static(include_bytes!( + "../assets/fonts/Inter-Regular.ttf" + ))), + ); + fonts.font_data.insert( + "inter-semibold".to_owned(), + Arc::new(FontData::from_static(include_bytes!( + "../assets/fonts/Inter-SemiBold.ttf" + ))), + ); + fonts.font_data.insert( + "jetbrains-mono".to_owned(), + Arc::new(FontData::from_static(include_bytes!( + "../assets/fonts/JetBrainsMono-Regular.ttf" + ))), + ); + + // Prepend our fonts; keep egui's defaults as fallback so Cyrillic gaps and + // the 🔒 emoji still render. + fonts + .families + .entry(FontFamily::Proportional) + .or_default() + .insert(0, "inter".to_owned()); + fonts + .families + .entry(FontFamily::Monospace) + .or_default() + .insert(0, "jetbrains-mono".to_owned()); + + // SemiBold family = Inter-SemiBold then the proportional fallback chain. + let mut strong = vec!["inter-semibold".to_owned()]; + strong.extend( + fonts + .families + .get(&FontFamily::Proportional) + .cloned() + .unwrap_or_default(), + ); + fonts + .families + .insert(FontFamily::Name(STRONG.into()), strong); + + ctx.set_fonts(fonts); +} + +fn build_style() -> egui::Style { + egui::Style { + visuals: build_visuals(), + text_styles: [ + (TextStyle::Heading, FontId::new(18.0, strong_family())), + (TextStyle::Body, FontId::new(13.0, FontFamily::Proportional)), + ( + TextStyle::Monospace, + FontId::new(13.0, FontFamily::Monospace), + ), + (TextStyle::Button, FontId::new(13.0, strong_family())), + ( + TextStyle::Small, + FontId::new(11.0, FontFamily::Proportional), + ), + ] + .into(), + spacing: egui::style::Spacing { + item_spacing: egui::vec2(8.0, 8.0), + button_padding: egui::vec2(12.0, 7.0), + interact_size: egui::vec2(40.0, 30.0), + ..Default::default() + }, + ..Default::default() + } +} + +fn build_visuals() -> egui::Visuals { + use color::{ACCENT, BORDER, BORDER_SOFT, INPUT_BG, SEC_BG, SEC_HOVER, SEC_PRESS, TXT}; + + let r = CornerRadius::same(6); + let mut v = egui::Visuals::dark(); + v.panel_fill = color::WINDOW_BG; + v.window_fill = color::WINDOW_BG; + v.extreme_bg_color = INPUT_BG; // TextEdit background + v.faint_bg_color = color::CARD_BG; + v.hyperlink_color = ACCENT; + v.selection = egui::style::Selection { + bg_fill: ACCENT.gamma_multiply(0.30), + stroke: Stroke::new(1.0, ACCENT), // also tints the selected radio dot + }; + + let set = |w: &mut egui::style::WidgetVisuals, fill, stroke| { + w.bg_fill = fill; + w.weak_bg_fill = fill; + w.bg_stroke = Stroke::new(1.0, stroke); + w.fg_stroke = Stroke::new(1.0, TXT); + w.corner_radius = r; + w.expansion = 0.0; + }; + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, BORDER_SOFT); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, TXT); + v.widgets.noninteractive.corner_radius = r; + set(&mut v.widgets.inactive, SEC_BG, BORDER); + set(&mut v.widgets.hovered, SEC_HOVER, BORDER); + set(&mut v.widgets.active, SEC_PRESS, ACCENT); // focused input → amber border + set(&mut v.widgets.open, SEC_HOVER, BORDER); + v +} + +/// Soft drop shadow for result cards (mockup: `0 2 8 rgba(0,0,0,.30)`). +pub fn card_shadow() -> egui::epaint::Shadow { + egui::epaint::Shadow { + offset: [0, 2], + blur: 8, + spread: 0, + color: Color32::from_black_alpha(76), + } +} + +/// Paints the brand hex mark (pointy-top hexagon outline + `0x`). +pub fn paint_hexmark(ui: &mut egui::Ui, size: f32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover()); + let painter = ui.painter(); + let c = rect.center(); + let radius = size * 0.46; + let points: Vec = (0..6_u8) + .map(|i| { + let a = std::f32::consts::FRAC_PI_2 + f32::from(i) * std::f32::consts::FRAC_PI_3; + egui::pos2(c.x + radius * a.cos(), c.y - radius * a.sin()) + }) + .collect(); + painter.add(egui::Shape::closed_line( + points, + Stroke::new(1.4, color::ACCENT), + )); + painter.text( + c, + egui::Align2::CENTER_CENTER, + "0x", + FontId::new(size * 0.34, FontFamily::Monospace), + color::ACCENT, + ); +} + +/// Builds the address text with the matched word highlighted in the accent +/// color (first occurrence), the `0x` prefix faint, the rest in mono color. +pub fn address_layout(address: &str, word: &str) -> LayoutJob { + let mono = FontId::new(13.0, FontFamily::Monospace); + let fmt = |c: Color32| TextFormat { + font_id: mono.clone(), + color: c, + ..Default::default() + }; + let mut job = LayoutJob::default(); + let hex = address.strip_prefix("0x").unwrap_or(address); + job.append("0x", 0.0, fmt(color::TXT_FAINT)); + match hex.find(word) { + Some(i) if !word.is_empty() => { + job.append(&hex[..i], 0.0, fmt(color::MONO_TXT)); + job.append(&hex[i..i + word.len()], 0.0, fmt(color::ACCENT)); + job.append(&hex[i + word.len()..], 0.0, fmt(color::MONO_TXT)); + } + _ => job.append(hex, 0.0, fmt(color::MONO_TXT)), + } + job +} diff --git a/docs/design-brief.md b/docs/design-brief.md new file mode 100644 index 0000000..1251fb8 --- /dev/null +++ b/docs/design-brief.md @@ -0,0 +1,134 @@ +# hexforge — Design Brief (for the Claude web Design tool) + +You are designing the UI for **hexforge**, a small **native desktop application** +(Linux / GNOME). It is a "vanity Ethereum address" generator: the user types a +short hex word and the app searches for an Ethereum address that contains that +word, showing the matching wallet(s). + +**This is NOT a website.** The app is built in **Rust with `egui`** (an +*immediate-mode* GUI). Please design within that medium's limits (see +Constraints). Your output is a **visual target + a design-tokens spec** that a +developer will re-implement in egui. + +--- + +## 1. What I need you to deliver + +1. **A single self-contained `index.html`** (inline CSS, no external assets) that + renders the app window in **dark theme** with realistic sample data and shows + the main states. This is for previewing/screenshotting the look. +2. **A "Design Tokens" section** (a table) listing every color, font, size, + spacing value, and corner radius. **This is the part that gets implemented**, + so be explicit and complete. +3. Keep it **clean, modern, minimal** — a focused single-purpose tool, not a + dashboard. Think "developer utility with taste". + +--- + +## 2. Hard constraints (target = egui, not a browser) + +The developer can faithfully reproduce these in egui: +- **Solid fill colors**, one **accent color**, text colors. +- **Rounded corners** (per widget/card). +- **Subtle soft drop-shadow** on cards/panels (one soft shadow, low opacity). +- **Borders / 1px strokes**. +- **Per-state widget colors**: normal / hover / active(pressed) / disabled. +- **Custom fonts** (one UI sans + one monospace) — name real, embeddable fonts + (e.g. **Inter** for UI, **JetBrains Mono** or **Fira Code** for addresses/keys). +- A simple **spinner** (built-in). +- Scrollable content area. + +Please **AVOID** (hard to reproduce in immediate-mode egui — do not rely on them): +- CSS gradients, glassmorphism / backdrop blur, background images/textures. +- Animations / transitions / hover-grow effects. +- Drop-shadow stacks, glows, complex layered effects. +- Icon-heavy UI (icons need a font/SVG pipeline). Prefer **text labels**; at most + a couple of simple glyphs. +- Flexbox/grid-specific layouts — layout is a **manual vertical stack** of rows. + +Window: **resizable, single window**, default ≈ **560 × 460 px**, min ≈ 480 × 420. + +--- + +## 3. The screen — structure, elements, states + +Single window, three regions stacked vertically: + +### A. Header / controls (fixed at top) +- App title: **`hexforge`** (heading). +- Row: label **`Слово:`** + a single-line **text input** (placeholder/hint: + `deadbeef`). +- Inline **validation error** (red text) under the input when the word is invalid, + e.g. `non-hex character 'y' (addresses use only 0-9, a-f)`. (Hidden when valid + or empty.) +- Row: label **`Где:`** + three **radio buttons**: `в начале` · `везде` · `в конце` + (start / anywhere / end). One selected. +- Row: three **buttons**: **`Искать`** (primary), **`Стоп`** (secondary), + **`Очистить`** (secondary). Buttons can be **disabled** (greyed) depending on + state — show a disabled style. +- **Status line** (changes by state — design all four): + - *Idle:* `Введите hex-слово и нажмите «Искать».` + - *Running:* a **spinner** + `1 234 567 ключей · 4500/с · найдено 1/1 · 12с` + - *Done:* `Готово · найдено 9` + - *Error:* red `Ошибка: …` + +### B. Results list (scrollable, middle — grows/scrolls) +- A small header: `Найдено: 9`. +- A vertical list of **wallet cards**. Each card: + - The **address** in monospace, e.g. `0x6a6c7f5d9dfb28a7608d55c6372facade5c203a5` + (the matched substring, e.g. `facade`, may be subtly highlighted — optional). + - A **`Копировать адрес`** button. + - A **`Показать секрет`** toggle button (becomes `Скрыть` when open). + - When revealed, two monospace lines appear inside the card: + - mnemonic: `reflect spawn enlist book race urban like sibling portion regret abstract rabbit` + - private key: `0x578bd86deea142e233662206610b4599557711cd40bde52a601fef53c0396f42` + - Secrets are **hidden by default** (security) — design the revealed and hidden + states. +- Empty-while-running placeholder: `Пока ничего не найдено — ищем…` + +### C. Footer (fixed at bottom) +- Small muted text: `🔒 Offline · ключи не сохраняются на диск · выпиши seed на бумагу` + +--- + +## 4. Tone & functional context (so the mockup feels right) +- It generates **private keys** → a security-conscious, trustworthy feel. Muted, + serious palette; the accent used sparingly for the primary action and matches. +- Works **offline**, saves **nothing** to disk. +- Brand hint: name = **hex** + **forge**. The current icon is a hexagon outline + with `0x` in an **amber/orange** accent (`#f5a623`) on near-black. Feel free to + keep amber as the accent or propose a better one — but give exact hex. + +--- + +## 5. Design Tokens to specify (the deliverable table) + +Give exact values for all of these: + +**Colors (hex):** window background · card/panel background · card border · +text primary · text secondary/muted · accent · accent hover · accent pressed · +on-accent (button text) · danger/error · success · monospace text · scrollbar. + +**Typography:** UI font family · monospace font family · sizes for {title, +heading, body, small/footer, monospace address} · weights {regular, medium/bold}. + +**Spacing:** base spacing scale (e.g. 4 / 8 / 12 / 16) · card inner padding · +gap between rows · gap between cards. + +**Shape:** corner radius for {window, card, button, input}. + +**States (give the color set for each):** button default / hover / pressed / +disabled · input normal / focused / error · card default (+ optional shadow: +offset, blur, color/opacity). + +--- + +## 6. Output, please +1. `index.html` (inline CSS, dark theme) rendering the window with the sample data + above, showing at least the **Done state with a few result cards** (one card + revealed, one hidden) + the controls + footer. +2. The **Design Tokens** table from §5. +3. (Optional) a short note on any choice that might be hard in egui, so the + developer can adapt. + +Keep it implementable under §2. Thank you! diff --git a/docs/design-mockup.html b/docs/design-mockup.html new file mode 100644 index 0000000..b4298ae --- /dev/null +++ b/docs/design-mockup.html @@ -0,0 +1,568 @@ + + + + + +hexforge — visual target + + + + + + + +
+ hexforge + visual target · dark theme + egui re-implementation spec + window 560 × 460 +
+ + +
+ + +
+
hexforge
+
+ + +
+
+ + +
+
+ + + 0x + +

hexforge

+ vanity eth +
+ +
+ Слово: +
facade
+
+ +
+ Где: +
+ в начале + везде + в конце +
+
+ +
+ + + +
+ +
+ + Готово · найдено 9 +
+
+ + +
+
+ Найдено: 9 + facade · везде +
+ + +
+
0x6a6c7f5d9dfb28a7608d55c6372facade5c203a5
+
+ + +
+
+
+ seed-фраза + reflect spawn enlist book race urban like sibling portion regret abstract rabbit +
+
+ приватный ключ + 0x578bd86deea142e233662206610b4599557711cd40bde52a601fef53c0396f42 +
+
+
+ + +
+
0x91b3facade77c0a4e2d8f15b6093ac7e0d4f8821
+
+ + +
+ +
+ + +
+
0xc4e90a7d2bfacade8f1306a9c7be452d0a3f9610
+
+ + +
+ +
+
+ + + +
+ + +
+

Состояния — status line

+
+
+ idle +
Введите hex-слово и нажмите «Искать».
+ + running +
1 234 567 ключей · 4500/с · найдено 1/1 · 12с
+ + done +
Готово · найдено 9
+ + error +
Ошибка: source of entropy unavailable
+
+
+ +

Состояния — input

+
+
+ normal +
deadbeef
+ + focused +
facade
+ + error +
deadbeefy
non-hex character 'y' (addresses use only 0-9, a-f)
+
+
+ +

Состояния — buttons & radio

+
+
+ primary +
+ + default · hover · pressed · disabled → + + + +
+ + secondary +
+ + + + +
+ + radio +
+ везде + в начале + в конце +
+
+
+ + +

Design Tokens — colors

+
+
+ + + + + + + + + + + +
tokenhex
window bg#14120D
card bg#1E1A13
input bg#100E09
border#2E281D
text primary#ECE6DA
text muted#968F82
mono text#D2CBBD
+ + + + + + + + + + + +
tokenhex
accent#F5A623
accent hover#FFB840
accent pressed#D98E14
on-accent#1A150B
danger#E8604C
success#62C07E
scrollbar#3A3326
+
+
+ +
+
+

Typography

+
+ + + + + + + + + + + +
UI fontInter
mono fontJetBrains Mono
title18px / 600
heading / label13px / 600
body13px / 400
small / footer11px / 400
mono address13px / 500
weights400 · 500 · 600 / 700
+
+
+ +
+

Spacing & shape

+
+ + + + + + + + + + + + +
scale4 · 8 · 12 · 16 · 20
card padding12px
gap between rows12px
gap between cards8px
radius window12px
radius card8px
radius button6px
radius input6px
card shadow0 2 8 rgba(0,0,0,.30)
+
+
+
+ +

State color sets

+
+ + + + + + + + +
widgetdefaulthoverpressed / focusdisabled / error
button primary#F5A623#FFB840#D98E14bg #221D15 · txt #6B6557
button secondary#221E16#2A2519#1A160Fbg #221D15 · txt #6B6557
inputborder #2E281Dborder #F5A623border #E8604C
card#1E1A13 · 1px #2E281Dshadow 0 2 8 / 30%
+
+ +

Заметки для egui-реализации

+
+

+ • Всё в рамках immediate-mode: сплошные заливки, один soft shadow на карточке (offset 0,2 · blur 8 · #000 @30%), 1px-бордеры, без градиентов/блюра/анимаций.
+ • Spinner в running-состоянии — встроенный egui-спиннер, перекрасить дугу в accent #F5A623.
+ • Подсветка совпадения: адрес рендерится как один моноширинный LayoutJob — буквы слова окрашены в accent, остальное в #D2CBBD, префикс 0x приглушён #6B6557.
+ • Шестиугольник 0x — единственный «иконочный» элемент: контур-полигон (stroke 1.4) + текст 0x. Можно нарисовать через Painter без шрифтового пайплайна.
+ • Радиокнопки — нативные egui radio (кружок + accent-заливка при выборе). Сегментацию не требуется.
+ • Метрики статуса (4500/с · 12с) — моноширинным для табличного выравнивания цифр.
+ • Секреты скрыты по умолчанию; раскрытие — мгновенное (без transition), inset-блок #100D08. +

+
+
+ + + +