feat(web): implement complete workspace with themes, tabs, sidebar, and mobile

Transform CalcText from a single-document calculator into a full workspace
application with multi-document support, theming, and responsive mobile experience.

- Theme system: 5 presets (Light, Dark, Matrix, Midnight, Warm) + accent colors
- Document model with localStorage persistence and auto-save
- Tab bar with keyboard shortcuts (Ctrl+N/W/Tab/1-9), rename, close
- Sidebar with search, recent, favorites, folders, templates, drag-and-drop
- 5 templates: Budget, Invoice, Unit Converter, Trip Planner, Loan Calculator
- Status bar with cursor position, engine status, dedication to Igor Cassel
- Results panel: type-specific colors, click-to-copy, error hints
- Format toolbar: H, B, I, //, color labels with live preview toggle
- Syntax highlighting using theme CSS variables
- Error hover tooltips
- Mobile: bottom results tray, sidebar drawer, touch targets, safe areas
- Docker multi-stage build (Rust WASM + Vite + Nginx)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 09:12:05 -04:00
parent 806e2f1ec6
commit 0d38bd3108
78 changed files with 8175 additions and 421 deletions

View File

@@ -12,6 +12,8 @@ pub enum ResultType {
DateTime,
TimeDelta,
Boolean,
Empty,
NonCalculable,
Error,
}
@@ -24,6 +26,8 @@ impl fmt::Display for ResultType {
ResultType::DateTime => write!(f, "DateTime"),
ResultType::TimeDelta => write!(f, "TimeDelta"),
ResultType::Boolean => write!(f, "Boolean"),
ResultType::Empty => write!(f, "Empty"),
ResultType::NonCalculable => write!(f, "NonCalculable"),
ResultType::Error => write!(f, "Error"),
}
}
@@ -168,6 +172,36 @@ impl CalcResult {
}
}
pub fn empty(span: Span) -> Self {
CalcResult {
value: CalcValue::Error {
message: "empty input".to_string(),
span: span.into(),
},
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::Empty,
display: String::new(),
raw_value: None,
},
}
}
pub fn non_calculable(span: Span) -> Self {
CalcResult {
value: CalcValue::Error {
message: "no expression found".to_string(),
span: span.into(),
},
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::NonCalculable,
display: String::new(),
raw_value: None,
},
}
}
pub fn error(message: &str, span: Span) -> Self {
CalcResult {
value: CalcValue::Error {
@@ -186,13 +220,34 @@ impl CalcResult {
pub fn result_type(&self) -> ResultType {
self.metadata.result_type
}
/// Returns true if this result is a computed value (not empty, non-calculable, or error).
pub fn is_calculable(&self) -> bool {
!matches!(
self.metadata.result_type,
ResultType::Empty | ResultType::NonCalculable | ResultType::Error
)
}
}
fn format_number(val: f64) -> String {
if val == val.floor() && val.abs() < 1e15 {
format!("{}", val as i64)
} else {
format!("{}", val)
// Round to 10 significant figures to avoid f64 noise
let abs = val.abs();
let magnitude = if abs > 0.0 { abs.log10().floor() as i32 } else { 0 };
let precision = (10 - magnitude - 1).max(0) as usize;
let formatted = format!("{:.prec$}", val, prec = precision);
// Strip trailing zeros after decimal point
if formatted.contains('.') {
formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
} else {
formatted
}
}
}