From 7fe41b929d079b3c0b245fb92c8650ae3bef1171 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 27 Jan 2026 10:52:09 +0100 Subject: [PATCH 1/7] Initial commit with task details Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/link-assistant/calculator/issues/44 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..969c6d30 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/link-assistant/calculator/issues/44 +Your prepared branch: issue-44-2e8fe384578e +Your prepared working directory: /tmp/gh-issue-solver-1769507526539 + +Proceed. From f3575b6286a2560b5e87e82ac432ddb1788663fe Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 27 Jan 2026 11:04:03 +0100 Subject: [PATCH 2/7] feat: Major UI improvements for Link.Calculator - Rebrand to "Link.Calculator" with SVG logo and public domain tagline - Remove "Expression" label from input field for cleaner UI - Move interpretation section before result and rename to "Input" - Add calculate button (=) and Enter key support for on-command calculation - Replace reactive updates with explicit calculation trigger - Rename "System" theme to "Auto" for clarity - Add "Automatic" as first language option - Add preferred currency setting with fiat and crypto currencies - Show computation time from Rust worker - Handle window resize for textarea auto-resize - Disable manual resize (auto-resize only) - Update all locale files with new translations Fixes #44 Co-Authored-By: Claude Opus 4.5 --- .../20260127_110314_ui_improvements.md | 17 ++ web/src/App.tsx | 267 ++++++++++++++---- .../components/AutoResizeTextarea.test.tsx | 6 +- web/src/components/AutoResizeTextarea.tsx | 2 +- web/src/i18n/index.test.ts | 39 ++- web/src/i18n/index.ts | 14 +- web/src/i18n/locales/ar.lino | 13 +- web/src/i18n/locales/de.lino | 13 +- web/src/i18n/locales/en.lino | 13 +- web/src/i18n/locales/fr.lino | 13 +- web/src/i18n/locales/hi.lino | 13 +- web/src/i18n/locales/ru.lino | 13 +- web/src/i18n/locales/zh.lino | 13 +- web/src/index.css | 108 ++++++- 14 files changed, 440 insertions(+), 104 deletions(-) create mode 100644 changelog.d/20260127_110314_ui_improvements.md diff --git a/changelog.d/20260127_110314_ui_improvements.md b/changelog.d/20260127_110314_ui_improvements.md new file mode 100644 index 00000000..4b4dcc6a --- /dev/null +++ b/changelog.d/20260127_110314_ui_improvements.md @@ -0,0 +1,17 @@ +### Changed + +- Rebranded to "Link.Calculator" with new SVG logo and updated tagline "Free open-source calculator dedicated to public domain" +- Renamed "System" theme option to "Auto" for clarity +- Added "Automatic" option as first choice in language selector for auto-detection +- Moved input interpretation section before the result section and renamed it to "Input" +- Removed "Expression" label from input field for cleaner UI +- Changed input field from reactive updates to calculate-on-command: now requires clicking equals button or pressing Enter +- Disabled manual resize indicator on textarea (auto-resize only) + +### Added + +- Calculate button (=) in the input field to trigger computation +- Enter key support to submit calculation +- Preferred currency setting with major fiat currencies and top 10 cryptocurrencies +- Computation time display showing how long calculations take +- Window resize handler for textarea auto-resize diff --git a/web/src/App.tsx b/web/src/App.tsx index 80b7fa8f..86391666 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,12 +1,66 @@ -import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'; +import { useState, useEffect, useCallback, useRef, lazy, Suspense, KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { useTheme, useUrlExpression, useDelayedLoading } from './hooks'; import { SUPPORTED_LANGUAGES, loadPreferences, savePreferences } from './i18n'; import { generateIssueUrl, type PageState } from './utils/reportIssue'; -import { AutoResizeTextarea, ColorCodedLino, RepeatingDecimalNotations } from './components'; +import { AutoResizeTextarea, ColorCodedLino, RepeatingDecimalNotations, type AutoResizeTextareaRef } from './components'; import type { CalculationResult, ErrorInfo } from './types'; +// SVG Logo component for Link.Calculator branding +const LinkCalculatorLogo = ({ size = 24 }: { size?: number }) => ( + + + + + + + + + + + + + + +); + +// Top 10 crypto currencies by market cap +const CRYPTO_CURRENCIES = [ + { code: 'BTC', name: 'Bitcoin' }, + { code: 'ETH', name: 'Ethereum' }, + { code: 'USDT', name: 'Tether' }, + { code: 'BNB', name: 'BNB' }, + { code: 'SOL', name: 'Solana' }, + { code: 'XRP', name: 'XRP' }, + { code: 'USDC', name: 'USD Coin' }, + { code: 'ADA', name: 'Cardano' }, + { code: 'DOGE', name: 'Dogecoin' }, + { code: 'AVAX', name: 'Avalanche' }, +]; + +// Major fiat currencies +const FIAT_CURRENCIES = [ + { code: 'USD', name: 'US Dollar' }, + { code: 'EUR', name: 'Euro' }, + { code: 'GBP', name: 'British Pound' }, + { code: 'JPY', name: 'Japanese Yen' }, + { code: 'CNY', name: 'Chinese Yuan' }, + { code: 'INR', name: 'Indian Rupee' }, + { code: 'RUB', name: 'Russian Ruble' }, + { code: 'BRL', name: 'Brazilian Real' }, + { code: 'CHF', name: 'Swiss Franc' }, + { code: 'CAD', name: 'Canadian Dollar' }, + { code: 'AUD', name: 'Australian Dollar' }, + { code: 'KRW', name: 'Korean Won' }, +]; + // Lazy load the math and plot components for better initial bundle size const MathRenderer = lazy(() => import('./components/MathRenderer')); const FunctionPlot = lazy(() => import('./components/FunctionPlot')); @@ -45,6 +99,48 @@ function translateError( return translated; } +/** + * Detect user's preferred currency from browser locale. + */ +function detectUserCurrency(): string { + try { + const locale = navigator.language || 'en-US'; + // Map common locales to their currencies + const localeToCurrency: Record = { + 'en-US': 'USD', + 'en-GB': 'GBP', + 'de-DE': 'EUR', + 'fr-FR': 'EUR', + 'es-ES': 'EUR', + 'it-IT': 'EUR', + 'ja-JP': 'JPY', + 'zh-CN': 'CNY', + 'zh-TW': 'TWD', + 'ko-KR': 'KRW', + 'ru-RU': 'RUB', + 'pt-BR': 'BRL', + 'hi-IN': 'INR', + 'ar-SA': 'SAR', + }; + + // Try exact match first + if (localeToCurrency[locale]) { + return localeToCurrency[locale]; + } + + // Try language-only match + const lang = locale.split('-')[0]; + const langMatch = Object.entries(localeToCurrency).find(([key]) => key.startsWith(lang + '-')); + if (langMatch) { + return langMatch[1]; + } + + return 'USD'; // Default to USD + } catch { + return 'USD'; + } +} + function App() { const { t, i18n } = useTranslation(); const { theme, resolvedTheme, setTheme } = useTheme(); @@ -58,9 +154,15 @@ function App() { const [settingsOpen, setSettingsOpen] = useState(false); const [ratesLoading, setRatesLoading] = useState(false); const [ratesInfo, setRatesInfo] = useState<{ date?: string; base?: string } | null>(null); + const [computationTime, setComputationTime] = useState(null); + const [preferredCurrency, setPreferredCurrency] = useState(() => { + const prefs = loadPreferences(); + return prefs.currency || detectUserCurrency(); + }); const workerRef = useRef(null); const settingsRef = useRef(null); + const textareaRef = useRef(null); // Delayed loading indicator (shows after 300ms) const showLoading = useDelayedLoading(loading, 300); @@ -80,6 +182,10 @@ function App() { setVersion(data.version); } else if (type === 'result') { setResult(data); + // Capture computation time from worker if provided + if (data.computation_time_ms !== undefined) { + setComputationTime(data.computation_time_ms); + } setLoading(false); } else if (type === 'error') { setResult({ @@ -89,6 +195,7 @@ function App() { success: false, error: data.error, }); + setComputationTime(null); setLoading(false); } else if (type === 'ratesLoading') { setRatesLoading(data.loading); @@ -116,26 +223,34 @@ function App() { }; }, [t]); - const calculate = useCallback((expression: string) => { - if (!expression.trim() || !wasmReady || !workerRef.current) { + const calculate = useCallback((expression?: string) => { + const expr = expression ?? input; + if (!expr.trim() || !wasmReady || !workerRef.current) { return; } setLoading(true); - workerRef.current.postMessage({ type: 'calculate', expression }); - }, [wasmReady]); + setComputationTime(null); + workerRef.current.postMessage({ type: 'calculate', expression: expr }); + }, [wasmReady, input]); + + // Handle Enter key press to trigger calculation + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + calculate(); + } + }, [calculate]); + // Handle window resize to auto-resize textarea useEffect(() => { - const debounce = setTimeout(() => { - if (input.trim()) { - calculate(input); - } else { - setResult(null); - } - }, 300); + const handleResize = () => { + textareaRef.current?.resize(); + }; - return () => clearTimeout(debounce); - }, [input, calculate]); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); const handleExampleClick = (example: string) => { setInput(example); @@ -155,6 +270,12 @@ function App() { savePreferences({ ...prefs, language: langCode }); }; + const handleCurrencyChange = (currencyCode: string) => { + setPreferredCurrency(currencyCode); + const prefs = loadPreferences(); + savePreferences({ ...prefs, currency: currencyCode }); + }; + const handleReportIssue = () => { const pageState: PageState = { expression: input, @@ -192,7 +313,10 @@ function App() {
-

{t('app.title')}

+

+ + Link.Calculator +

@@ -234,6 +358,7 @@ function App() { value={i18n.language} onChange={(e) => handleLanguageChange(e.target.value)} > + {SUPPORTED_LANGUAGES.map((lang) => (
+
+ + +
)} @@ -250,47 +397,75 @@ function App() {
-
setInput(e.target.value)} + onKeyDown={handleKeyDown} placeholder={t('input.placeholder')} disabled={!wasmReady} autoFocus - minRows={1} + minRows={2} maxRows={10} /> - {input && ( +
+ {input && ( + + )} - )} +
+ {/* Input interpretation section - before Result */} + {result && result.success && result.lino_interpretation && ( +
+

{t('result.input')}

+
+ +
+
+ )} +

{t('result.title')}

- {showLoading && ( -
-
- {t('result.calculating')} -
- )} +
+ {showLoading && ( +
+
+ {t('result.calculating')} +
+ )} + {!showLoading && computationTime !== null && ( + + {computationTime < 1 ? '<1' : computationTime.toFixed(0)} ms + + )} +
{!wasmReady ? ( @@ -302,17 +477,7 @@ function App() { <> {result.success ? ( <> - {/* Section 1: Interpretation (mandatory) - Links notation with color-coded parentheses */} - {result.lino_interpretation && ( -
-

{t('result.interpretation', 'Interpretation')}

-
- -
-
- )} - - {/* Section 2: Result (mandatory) */} + {/* Section 1: Result (mandatory) */} {result.is_symbolic && result.latex_input && result.latex_result ? (
{result.result}
}> @@ -408,7 +573,7 @@ function App() {