├── .env ├── public ├── robots.txt ├── favicon.ico ├── pwa-192x192.png ├── pwa-512x512.png ├── apple-touch-icon.png ├── fonts │ ├── inter-v18-latin-800.ttf │ ├── inter-v18-latin-800.woff2 │ ├── inter-v18-latin-italic.ttf │ ├── inter-v18-latin-regular.ttf │ ├── inter-v18-latin-italic.woff2 │ └── inter-v18-latin-regular.woff2 ├── kalkki.svg └── third-party-licenses.txt ├── src ├── vite-env.d.ts ├── util │ ├── themes.ts │ ├── use-object-state.ts │ ├── licenses.json │ ├── index.ts │ └── number-formatting.ts ├── components │ ├── Logo.tsx │ ├── SelfDestructButton.tsx │ ├── HistoryLine.tsx │ ├── AutoUpdate.tsx │ ├── TopBar.tsx │ └── MathInput.tsx ├── main.tsx ├── styles │ ├── themes │ │ ├── speedcrunch.scss │ │ ├── gruvbox.scss │ │ ├── dracula.scss │ │ └── catpuccin.scss │ ├── _history.scss │ ├── _auto-update.scss │ ├── app.scss │ ├── _input.scss │ ├── _page.scss │ ├── _top-bar.scss │ └── index.css ├── lang │ ├── index.ts │ ├── jp.ts │ ├── nl.ts │ ├── en.ts │ ├── de.ts │ ├── sv.ts │ └── fi.ts ├── math │ ├── LICENSE │ ├── worker.ts │ ├── latex-to-math.ts │ ├── documentation.ts │ ├── internal │ │ ├── functions.ts │ │ ├── tokenizer.ts │ │ ├── large-number.ts │ │ └── evaluator.ts │ ├── prettify.ts │ ├── index.ts │ └── syntax-highlighter.tsx ├── pages │ ├── About.tsx │ └── Copyright.tsx ├── test │ └── speedcrunch.test.ts └── App.tsx ├── src-tauri ├── build.rs ├── icons │ ├── 32x32.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ └── 128x128@2x.png ├── .gitignore ├── src │ ├── main.rs │ └── lib.rs ├── capabilities │ └── default.json ├── Cargo.toml └── tauri.conf.json ├── .licensesrc.json ├── .husky └── pre-commit ├── .glfrc.json ├── tsconfig.json ├── .gitignore ├── Dockerfile ├── biome.json ├── tsconfig.node.json ├── LICENSE ├── CHANGELOG.md ├── tsconfig.app.json ├── package.json ├── .github └── workflows │ ├── deploy.yml │ └── publish.yml ├── index.html ├── README.md └── vite.config.ts /.env: -------------------------------------------------------------------------------- 1 | VITE_ANALYTICS_SCRIPT= -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /.licensesrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "fields": ["name", "author", "licenseType", "link"] 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | bun run check --staged --no-errors-on-unmatched 2 | git update-index --again -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/public/pwa-512x512.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /.glfrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "replace": { 3 | "@tauri-apps/api": "./node_modules/@tauri-apps/api/LICENSE_MIT" 4 | } 5 | } -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | /gen/schemas 5 | -------------------------------------------------------------------------------- /public/fonts/inter-v18-latin-800.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/public/fonts/inter-v18-latin-800.ttf -------------------------------------------------------------------------------- /public/fonts/inter-v18-latin-800.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/public/fonts/inter-v18-latin-800.woff2 -------------------------------------------------------------------------------- /public/fonts/inter-v18-latin-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/public/fonts/inter-v18-latin-italic.ttf -------------------------------------------------------------------------------- /public/fonts/inter-v18-latin-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/public/fonts/inter-v18-latin-regular.ttf -------------------------------------------------------------------------------- /public/fonts/inter-v18-latin-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/public/fonts/inter-v18-latin-italic.woff2 -------------------------------------------------------------------------------- /public/fonts/inter-v18-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/kalkki/HEAD/public/fonts/inter-v18-latin-regular.woff2 -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | app_lib::run(); 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "enables the default permissions", 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:default", 10 | "opener:default" 11 | ] 12 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "paths": { 9 | "react": ["./node_modules/preact/compat/"], 10 | "react-dom": ["./node_modules/preact/compat/"], 11 | "@/*": ["./src/*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /public/kalkki.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/util/themes.ts: -------------------------------------------------------------------------------- 1 | export const themes = { 2 | default: "Kalkki", 3 | dracula: "Dracula", 4 | "catpuccin-mocha": "Catpuccin Mocha", 5 | "catpuccin-frappe": "Catpuccin Frappe", 6 | "catpuccin-macchiato": "Catpuccin Macchiato", 7 | "catpuccin-latte": "Catpuccin Latte", 8 | "gruvbox-dark": "Gruvbox Dark", 9 | "gruvbox-light": "Gruvbox Light", 10 | "speedcrunch-terminal": "Terminal", 11 | }; 12 | -------------------------------------------------------------------------------- /src/util/use-object-state.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "preact/hooks"; 2 | 3 | export function useObjectState( 4 | defaultValue: T, 5 | ): [T, (value: Partial) => void] { 6 | const [value, setStateValue] = useState(defaultValue); 7 | const setValue = (newValue: Partial) => { 8 | setStateValue((currentValue) => ({ ...currentValue, ...newValue })); 9 | }; 10 | 11 | return [value, setValue]; 12 | } 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:1.2-alpine AS deps 2 | WORKDIR /app 3 | COPY package.json bun.lock ./ 4 | RUN bun install 5 | 6 | 7 | FROM oven/bun:1.2-alpine AS build 8 | WORKDIR /app 9 | COPY --from=deps /app/node_modules ./node_modules 10 | COPY . . 11 | ENV VITE_DESKTOP_BUILD=true 12 | RUN apk add git && bun run build 13 | 14 | 15 | FROM nginx:1.27.4-alpine 16 | WORKDIR /usr/share/nginx/html/ 17 | COPY --from=build /app/dist /usr/share/nginx/html/ 18 | EXPOSE 80 -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "include": ["src/**/*"] 10 | }, 11 | "formatter": { 12 | "enabled": true, 13 | "indentStyle": "tab" 14 | }, 15 | "organizeImports": { 16 | "enabled": true 17 | }, 18 | "linter": { 19 | "enabled": true, 20 | "rules": { 21 | "recommended": true, 22 | "style": { 23 | "noParameterAssign": "off" 24 | } 25 | } 26 | }, 27 | "javascript": { 28 | "formatter": { 29 | "quoteStyle": "double" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from "preact/jsx-runtime"; 2 | 3 | export default function Logo(props: JSX.SVGAttributes) { 4 | return ( 5 | 13 | Kalkki logo 14 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "preact"; 2 | import "./styles/index.css"; 3 | import { App } from "./App.tsx"; 4 | 5 | const fullScreen = 6 | import.meta.env.VITE_DESKTOP_BUILD === "true" || 7 | window.matchMedia("(display-mode: standalone)").matches || 8 | JSON.parse(localStorage.getItem("kalkki-options") ?? "{}")?.fullScreen === 9 | true; 10 | 11 | if (!fullScreen) { 12 | document.body.classList.add("limit-size"); 13 | } 14 | // biome-ignore lint/style/noNonNullAssertion: React 15 | render(, document.getElementById("app")!); 16 | 17 | // Show tauri window after render 18 | if ("__TAURI__" in window) { 19 | const tauri = window.__TAURI__ as { 20 | core: { invoke: (func: string) => void }; 21 | }; 22 | tauri.core.invoke("show_kalkki_window"); 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Kalkki — scientific calculator for the web 2 | Copyright (C) 2025 Roni Äikäs (https://raikas.dev) 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU Affero General Public License as 6 | published by the Free Software Foundation, either version 3 of the 7 | License, or (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU Affero General Public License for more details. 13 | 14 | You should have received a copy of the GNU Affero General Public License 15 | along with this program. If not, see . -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kalkki" 3 | version = "0.0.0" 4 | description = "An easy-to-use and fast scientific calculator" 5 | authors = ["Roni Äikäs"] 6 | license = "AGPLv3" 7 | repository = "https://github.com/raikasdev/kalkki" 8 | edition = "2021" 9 | rust-version = "1.77.2" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [lib] 14 | name = "app_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2.0.5", features = [] } 19 | 20 | [dependencies] 21 | serde_json = "1.0" 22 | serde = { version = "1.0", features = ["derive"] } 23 | log = "0.4" 24 | tauri = { version = "2.3.1", features = [] } 25 | tauri-plugin-log = "2.0.0-rc" 26 | tauri-plugin-opener = "2" 27 | -------------------------------------------------------------------------------- /src/styles/themes/speedcrunch.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on Speedcrunch themes https://bitbucket.org/heldercorreia/speedcrunch 3 | * Licensed under GPLv2 4 | * See https://bitbucket.org/heldercorreia/speedcrunch/src/master/LICENSE for more information 5 | */ 6 | 7 | body.theme-speedcrunch-terminal { 8 | --theme-background: #300a24; 9 | --theme-text-color: #ad7fa8; 10 | --theme-answer: #689fcf; 11 | --theme-warning: #ef2928; 12 | --theme-input-background: #26001a; 13 | --theme-cursor: #8c648c; 14 | --theme-symbol-litr: #ffffff; 15 | --theme-symbol-oper: #c4a000; 16 | --theme-symbol-func: #ef2928; 17 | --theme-symbol-var: #4a9a07; 18 | --theme-symbol-separator: #ad7fa8; 19 | --theme-symbol-rbrk: #ad7fa8; 20 | --theme-symbol-lbrk: #ad7fa8; 21 | --theme-link-color: #ef2928; 22 | --theme-link-color-secondary: #f24746; 23 | } -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 2 | pub fn run() { 3 | tauri::Builder::default() 4 | .plugin(tauri_plugin_opener::init()) 5 | .setup(|app| { 6 | if cfg!(debug_assertions) { 7 | app.handle().plugin( 8 | tauri_plugin_log::Builder::default() 9 | .level(log::LevelFilter::Info) 10 | .build(), 11 | )?; 12 | } 13 | Ok(()) 14 | }) 15 | .invoke_handler(tauri::generate_handler![show_kalkki_window]) 16 | .run(tauri::generate_context!()) 17 | .expect("error while running tauri application"); 18 | } 19 | 20 | #[tauri::command] 21 | async fn show_kalkki_window(webview_window: tauri::WebviewWindow) { 22 | webview_window.show().unwrap(); 23 | } -------------------------------------------------------------------------------- /src/lang/index.ts: -------------------------------------------------------------------------------- 1 | import { de } from "@/lang/de"; 2 | import { en } from "@/lang/en"; 3 | import { jp } from "@/lang/jp"; 4 | import { nl } from "@/lang/nl"; 5 | import { sv } from "@/lang/sv"; 6 | import { fi } from "./fi"; 7 | 8 | const languages = { 9 | fi, 10 | en, 11 | sv, 12 | nl, 13 | de, 14 | jp, 15 | } as const; 16 | 17 | type TranslationKey = keyof typeof fi; 18 | export type Language = keyof typeof languages; 19 | 20 | export function translate(key: TranslationKey, lang: Language = "fi") { 21 | return languages[lang][key] ?? languages.en[key]; 22 | } 23 | 24 | export function getDefaultLanguage(): Language { 25 | const navigatorLang = navigator.language.split("-")[0]; 26 | if (navigatorLang in languages) { 27 | return navigatorLang as Language; 28 | } 29 | return "fi"; 30 | } 31 | 32 | export function getLanguages(): Language[] { 33 | return Object.keys(languages) as Language[]; 34 | } 35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.4 2 | 3 | - Put LaTeX support behind environmental variable for now 4 | 5 | # 0.2.3 6 | 7 | - Remove experimental number formatter 8 | 9 | # 0.2.2 10 | 11 | - Fix theme switcher on Webkit (Tauri) 12 | 13 | # 0.2.1 14 | 15 | - Improve Dockerfile 16 | - Remove decimal.js 17 | - Update third party licenses 18 | 19 | # 0.2.0 20 | 21 | - Fix tangent precision calculation 22 | - Add translations 23 | - Add Tauri native executables 24 | - Make app full screen on Docker, PWA or Tauri and small by default on browser 25 | - Fix overlay styles on Firefox 26 | - Allow brackets as implicit multiplication 27 | - Fix implicit multiplication order to match SpeedCrunch 28 | - Add Dutch and German translations (thanks Trimpsuz) 29 | - Add Biome for formatting and linting 30 | - Switch to AGPLv3 31 | - Add support for ** (^) 32 | - Add syntax highlighting and themes 33 | - Add integer division (\) 34 | - Optimize factorial speed and number formatting 35 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "skipLibCheck": true, 9 | "paths": { 10 | "react": ["./node_modules/preact/compat/"], 11 | "react-dom": ["./node_modules/preact/compat/"], 12 | "@/*": ["./src/*"] 13 | }, 14 | 15 | /* Bundler mode */ 16 | "moduleResolution": "bundler", 17 | "allowImportingTsExtensions": true, 18 | "isolatedModules": true, 19 | "moduleDetection": "force", 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "jsxImportSource": "preact", 23 | 24 | /* Linting */ 25 | "strict": true, 26 | "noUnusedLocals": true, 27 | "noUnusedParameters": true, 28 | "noFallthroughCasesInSwitch": true, 29 | "noUncheckedSideEffectImports": true, 30 | }, 31 | "include": ["src"] 32 | } 33 | -------------------------------------------------------------------------------- /src/styles/_history.scss: -------------------------------------------------------------------------------- 1 | .history { 2 | padding: 0.5rem 0.5rem; 3 | height: 100%; 4 | overflow-y: auto; 5 | overflow-x: hidden; 6 | flex-grow: 1; 7 | display: flex; 8 | flex-direction: column-reverse; 9 | position: relative; 10 | scrollbar-width: thin; 11 | gap: 1rem; 12 | } 13 | 14 | .history-line { 15 | display: flex; 16 | flex-direction: column; 17 | 18 | .expression, 19 | .latex-warning { 20 | color: var(--theme-text-color, rgba(255, 255, 255, 0.8)); 21 | font-size: 14px; 22 | line-height: 1.2; 23 | } 24 | 25 | p { 26 | margin: 0; 27 | } 28 | 29 | .answer { 30 | color: var(--theme-answer, white); 31 | word-break: break-all; 32 | } 33 | 34 | .equals { 35 | user-select: none; 36 | color: var(--theme-symbol-oper); 37 | } 38 | 39 | .latex-warning { 40 | color: var(--theme-warning, oklch(.666 .179 58.318)); 41 | display: flex; 42 | align-items: flex-end; 43 | gap: 0.25rem; 44 | height: 21px; 45 | margin-right: 0.25rem; 46 | user-select: none; 47 | margin-bottom: 0.5rem; 48 | } 49 | } -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "productName": "Kalkki", 4 | "version": "../package.json", 5 | "identifier": "fi.mikroni.kalkki", 6 | "build": { 7 | "frontendDist": "../dist", 8 | "devUrl": "http://localhost:5173", 9 | "beforeDevCommand": "bun run dev:desktop", 10 | "beforeBuildCommand": "bun run build:desktop" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "Kalkki", 16 | "width": 500, 17 | "height": 640, 18 | "resizable": true, 19 | "fullscreen": false, 20 | "minWidth": 200, 21 | "minHeight": 150, 22 | "visible": false 23 | } 24 | ], 25 | "security": { 26 | "csp": null 27 | }, 28 | "withGlobalTauri": true 29 | }, 30 | "bundle": { 31 | "active": true, 32 | "targets": "all", 33 | "icon": [ 34 | "icons/32x32.png", 35 | "icons/128x128.png", 36 | "icons/128x128@2x.png", 37 | "icons/icon.icns", 38 | "icons/icon.ico" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/math/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 The Matriculation Examination Board of Finland 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kalkki", 3 | "private": true, 4 | "version": "0.2.5-nightly", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "dev:desktop": "VITE_DESKTOP_BUILD=true vite", 9 | "build": "vite build", 10 | "build:desktop": "VITE_DESKTOP_BUILD=true vite build", 11 | "preview": "vite preview", 12 | "licenses": "npx generate-license-file --input package.json --output public/third-party-licenses.txt && npx license-report --output=json --config .licensesrc.json > src/util/licenses.json", 13 | "lint": "biome lint --fix", 14 | "format": "biome format --fix", 15 | "check": "biome check --fix", 16 | "prepare": "husky" 17 | }, 18 | "dependencies": { 19 | "@tauri-apps/plugin-opener": "^2.2.6", 20 | "gmp-wasm": "^1.3.2", 21 | "latex-utensils": "^6.2.0", 22 | "lucide-react": "^0.475.0", 23 | "neverthrow": "^8.1.1", 24 | "preact": "^10.26.2", 25 | "ts-pattern": "5.2.0" 26 | }, 27 | "devDependencies": { 28 | "@biomejs/biome": "1.9.4", 29 | "@preact/preset-vite": "^2.10.1", 30 | "@tauri-apps/cli": "^2.3.1", 31 | "@types/bun": "^1.2.2", 32 | "husky": "^9.1.7", 33 | "sass-embedded": "^1.85.0", 34 | "typescript": "~5.7.2", 35 | "vite": "^6.1.0", 36 | "vite-plugin-pwa": "^0.21.1" 37 | }, 38 | "trustedDependencies": [ 39 | "husky" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/math/worker.ts: -------------------------------------------------------------------------------- 1 | import { calculate } from "@/math/index"; 2 | import { LargeNumber } from "@/math/internal/large-number"; 3 | import { deserializeUserspace, serializeUserspace } from "@/util"; 4 | import { toSignificantDigits } from "@/util/number-formatting"; 5 | import { ok } from "neverthrow"; 6 | 7 | type Request = { 8 | type: "init" | "calculate"; 9 | id?: string; 10 | data?: [string, string, string, "deg" | "rad"]; 11 | }; 12 | 13 | self.onmessage = async (e) => { 14 | const req: Request = JSON.parse(e.data); 15 | if (req.type === "init") { 16 | await LargeNumber.init(); 17 | return; 18 | } 19 | if (!req.data || !req.id) return; 20 | const [exp, ans, userSpace, deg] = req.data; 21 | await LargeNumber.init(); 22 | 23 | const res = calculate( 24 | exp, 25 | new LargeNumber(ans), 26 | deserializeUserspace(JSON.parse(userSpace)), 27 | deg, 28 | ); 29 | if (res.isErr()) { 30 | self.postMessage( 31 | JSON.stringify({ 32 | id: req.id, 33 | ...res, 34 | }), 35 | ); 36 | } else { 37 | // Round the number to 100 digits for transport and storage to save memory 38 | const val: Record = {}; 39 | if (res.value.value) { 40 | val.value = toSignificantDigits(res.value.value?.toString() ?? "", 100); 41 | } 42 | if (res.value.userSpace) { 43 | val.userSpace = JSON.stringify(serializeUserspace(res.value.userSpace)); 44 | } 45 | self.postMessage( 46 | JSON.stringify({ 47 | id: req.id, 48 | ...ok(val), 49 | }), 50 | ); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/components/SelfDestructButton.tsx: -------------------------------------------------------------------------------- 1 | import { type Language, translate } from "@/lang"; 2 | import { TriangleAlert } from "lucide-react"; 3 | import { useCallback, useState } from "preact/hooks"; 4 | 5 | export default function SelfDestructButton({ 6 | language, 7 | }: { language: Language }) { 8 | const [confirm, setConfirm] = useState(-1); 9 | 10 | const destruct = useCallback(() => { 11 | if (confirm === -1) { 12 | let confirm = 25; 13 | setConfirm(confirm); 14 | const interval = setInterval(() => { 15 | confirm -= 1; 16 | setConfirm(confirm); 17 | 18 | if (confirm <= 0) { 19 | clearInterval(interval); 20 | setTimeout(() => { 21 | if (confirm === 0) setConfirm(-1); // Cancel 22 | }, 5000); 23 | } 24 | }, 100); 25 | return; 26 | } 27 | if (confirm !== 0) return; 28 | localStorage.removeItem("kalkki-options"); 29 | localStorage.removeItem("kalkki-history"); 30 | 31 | window.location.reload(); 32 | }, [confirm]); 33 | 34 | return ( 35 |
  • 36 | 52 |
  • 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/math/latex-to-math.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Turns a LaTeX string into evaluation compatible format. 3 | * @param latex LaTeX String 4 | * @returns Math string (like (1/2)^5*2+1) 5 | */ 6 | export function latexToMath(latex: string): string { 7 | // Replace \left( with ( and \right) with ) 8 | latex = latex.replace(/\\left\(/g, "(").replace(/\\right\)/g, ")"); 9 | 10 | // Remove LaTeX syntax for fractions 11 | latex = latex.replace(/\\frac\{([^}]*)\}\{([^}]*)\}/g, "($1)/($2)"); 12 | 13 | // Convert powers 14 | latex = latex.replace(/\(([^)]+)\)\^\{([^}]+)\}/g, "($1)^($2)"); 15 | latex = latex.replace(/\^\{([^}]+)\}/g, "^($1)"); // Handle simple exponents 16 | 17 | // Convert multiplication and remove unnecessary symbols 18 | latex = latex.replace(/\\cdot/g, "*"); 19 | 20 | // Handle trigonometric functions with parentheses 21 | latex = latex.replace(/\\sin\s*\({0,1}([^\s)]+)\){0,1}/g, "sin($1)"); 22 | latex = latex.replace(/\\cos\s*\({0,1}([^\s)]+)\){0,1}/g, "cos($1)"); 23 | latex = latex.replace(/\\tan\s*\({0,1}([^\s)]+)\){0,1}/g, "tan($1)"); 24 | 25 | // Remove degree symbol 26 | latex = latex.replace(/°/g, ""); 27 | 28 | // Handle logarithms with parentheses and correct base notation 29 | // Logarithm with base 30 | latex = latex.replace( 31 | /\\log\s*_\{{0,1}([^}]*)\}{0,1}\s*\(([^)]+)\)/g, 32 | "log($1; $2)", 33 | ); 34 | // Logarithm without base (assuming base 10) 35 | latex = latex.replace(/\\log\s*\(([^)]+)\)/g, "lg($1)"); 36 | 37 | // Clean up any remaining LaTeX syntax that wasn't covered 38 | latex = latex.replace(/\\(?!\\)/g, ""); // Remove backslashes not part of escaped sequences 39 | 40 | return latex; 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | 2 | # Simple workflow for deploying static content to GitHub Pages 3 | name: Deploy to Pages 4 | 5 | on: 6 | # Runs on pushes targeting the default branch 7 | push: 8 | branches: ["master"] 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 14 | permissions: 15 | contents: read 16 | pages: write 17 | id-token: write 18 | 19 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 20 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 21 | concurrency: 22 | group: "pages" 23 | cancel-in-progress: false 24 | 25 | jobs: 26 | # Single deploy job since we're just deploying 27 | deploy: 28 | environment: 29 | name: github-pages 30 | url: ${{ steps.deployment.outputs.page_url }} 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | - uses: oven-sh/setup-bun@v2 36 | 37 | - run: bun install 38 | - run: bun run build 39 | env: 40 | VITE_ANALYTICS_SCRIPT: 41 | - name: Setup Pages 42 | uses: actions/configure-pages@v5 43 | - name: Upload artifact 44 | uses: actions/upload-pages-artifact@v3 45 | with: 46 | path: './dist' 47 | - name: Deploy to GitHub Pages 48 | id: deployment 49 | uses: actions/deploy-pages@v4 50 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Kalkki 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
    18 | 19 | 48 | %VITE_ANALYTICS_SCRIPT% 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/pages/About.tsx: -------------------------------------------------------------------------------- 1 | import Logo from "@/components/Logo"; 2 | import { type Language, translate } from "@/lang"; 3 | import { CircleX } from "lucide-react"; 4 | 5 | export default function AboutPage({ 6 | visible, 7 | setVisible, 8 | language, 9 | }: { language: Language; visible: boolean; setVisible: (v: boolean) => void }) { 10 | return ( 11 |
    15 | 18 |
    19 |
    20 | 21 |
    22 |

    Kalkki

    23 |

    24 | {translate("aboutVersion", language)}{" "} 25 | {import.meta.env.VITE_APP_VERSION} (Git{" "} 26 | {import.meta.env.VITE_GIT_COMMIT}) 27 |

    28 |
    29 |

    {translate("aboutFromStudents", language)}

    30 |

    36 |

    {translate("aboutThanks", language)}

    37 |

    {translate("aboutThanksTsry", language)}

    38 |

    {translate("aboutThanksYTL", language)}

    39 | 40 |

    {translate("aboutLicense", language)}

    41 |

    {translate("aboutLicenseGPL", language)}

    42 | 43 | Copyright (C) 2025 Roni Äikäs{" "} 44 | (https://raikas.dev) 45 | 46 |
    47 |
    48 |
    49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/styles/themes/gruvbox.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on https://github.com/morhetz/gruvbox licensed under MIT 3 | */ 4 | body.theme-gruvbox-dark { 5 | --theme-background: #282828; /* s:bg0 */ 6 | --theme-text-color: #ebdbb2; /* s:fg1 */ 7 | --theme-comment-color: #928374; /* s:gray */ 8 | --theme-link-color: #a89984; /* s:fg4 */ 9 | --theme-link-color-secondary: #b8a994; /* Lighter s:fg4 */ 10 | --theme-answer: #b8bb26; /* s:green */ 11 | --theme-warning: #fb4934; /* s:red */ 12 | --theme-input-background: #3c3836; /* s:bg1 */ 13 | --theme-cursor: #fe8019; /* s:orange */ 14 | --theme-symbol-litr: #d3869b; /* s:purple */ 15 | --theme-symbol-oper: #83a598; /* s:blue */ 16 | --theme-symbol-func: #8ec07c; /* s:aqua */ 17 | --theme-symbol-var: #83a598; /* s:blue */ 18 | --theme-symbol-separator: #504945; /* s:bg2 */ 19 | --theme-symbol-rbrk: #ebdbb2; /* s:fg1 */ 20 | --theme-symbol-lbrk: #ebdbb2; /* s:fg1 */ 21 | } 22 | 23 | body.theme-gruvbox-light { 24 | --theme-background: #fbf1c7; /* s:bg0 */ 25 | --theme-text-color: #3c3836; /* s:fg1 */ 26 | --theme-comment-color: #928374; /* s:gray */ 27 | --theme-link-color: #a89984; /* s:fg4 */ 28 | --theme-link-color-secondary: #988974; /* Darker s:fg4 */ 29 | --theme-answer: #79740e; /* s:green */ 30 | --theme-warning: #9d0006; /* s:red */ 31 | --theme-input-background: #ebdbb2; /* s:bg1 */ 32 | --theme-cursor: #af3a03; /* s:orange */ 33 | --theme-symbol-litr: #8f3f71; /* s:purple */ 34 | --theme-symbol-oper: #076678; /* s:blue */ 35 | --theme-symbol-func: #427b58; /* s:aqua */ 36 | --theme-symbol-var: #076678; /* s:blue */ 37 | --theme-symbol-separator: #d5c4a1; /* s:bg2 */ 38 | --theme-symbol-rbrk: #3c3836; /* s:fg1 */ 39 | --theme-symbol-lbrk: #3c3836; /* s:fg1 */ 40 | } -------------------------------------------------------------------------------- /src/styles/_auto-update.scss: -------------------------------------------------------------------------------- 1 | .auto-update-toast { 2 | position: fixed; 3 | bottom: calc(24px + 1rem); // The bottom bar takes space 4 | right: 1rem; 5 | margin-left: 1rem; 6 | z-index: 50; 7 | transition: transform 0.3s ease-in-out; 8 | user-select: none; 9 | animation: slideInUp 0.3s cubic-bezier(.25, .46, .45, .94) forwards; 10 | 11 | 12 | .auto-update-toast-container { 13 | background-color: rgba(26, 26, 26, 0.95); 14 | backdrop-filter: blur(4px); 15 | border-radius: 0.5rem; 16 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 17 | padding: 1rem; 18 | color: #f1f1f1; 19 | display: flex; 20 | flex-direction: column; 21 | gap: 0.75rem; 22 | border: 1px solid rgba(128, 128, 128, 0.2); 23 | } 24 | 25 | .auto-update-toast-title { 26 | font-weight: 600; 27 | font-size: 1rem; 28 | color: #ffffff; 29 | margin: 0; 30 | } 31 | 32 | .auto-update-toast-message { 33 | flex: 1; 34 | font-size: 0.9375rem; 35 | line-height: 1.5; 36 | color: #e0e0e0; 37 | margin: 0; 38 | max-width: 380px; 39 | } 40 | 41 | .auto-update-toast-actions { 42 | display: flex; 43 | justify-content: flex-end; 44 | } 45 | 46 | .auto-update-toast-button { 47 | padding: 0.5rem 1rem; 48 | background-color: #B2DC5B; 49 | color: #1A1A1A; 50 | border-radius: 0.375rem; 51 | font-weight: 500; 52 | font-size: 0.875rem; 53 | transition: background-color 0.2s; 54 | border: none; 55 | cursor: pointer; 56 | 57 | &:hover { 58 | background-color: #a1cb4a; 59 | } 60 | 61 | &:active, 62 | &:visited { 63 | background-color: #B2DC5B; 64 | } 65 | } 66 | } 67 | 68 | @keyframes slideInUp { 69 | from { 70 | transform: translateY(100%); 71 | opacity: 0; 72 | } 73 | to { 74 | transform: translateY(0); 75 | opacity: 1; 76 | } 77 | } -------------------------------------------------------------------------------- /src/styles/app.scss: -------------------------------------------------------------------------------- 1 | 2 | @use 'history'; 3 | @use 'input'; 4 | @use 'top-bar'; 5 | @use 'auto-update'; 6 | @use 'page'; 7 | 8 | // Import themes 9 | @use 'themes/catpuccin'; 10 | @use 'themes/dracula'; 11 | @use 'themes/gruvbox'; 12 | @use 'themes/speedcrunch'; 13 | 14 | #app { 15 | display: flex; 16 | flex-direction: column; 17 | justify-content: space-between; 18 | height: 100vh; 19 | box-sizing: border-box; 20 | width: 100vw; 21 | } 22 | 23 | @supports (max-height: 100dvh) { 24 | #app { 25 | height: 100dvh; 26 | } 27 | } 28 | 29 | .welcome-message { 30 | position: absolute; 31 | top: 4rem; 32 | left: 0; 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | text-align: center; 37 | padding: 2rem 1rem; 38 | width: 100%; 39 | box-sizing: border-box; 40 | transition: opacity 150ms cubic-bezier(.25, .46, .45, .94); 41 | color: var(--theme-text-color, rgba(255, 255, 255, 0.95)); 42 | 43 | h1 { 44 | margin: 0.5rem 0; 45 | } 46 | 47 | p { 48 | margin: 0.25rem 0; 49 | } 50 | 51 | svg { 52 | color: var(--theme-logo-color, #B2DC5B); 53 | } 54 | 55 | a { 56 | color: var(--theme-link-color, #B2DC5B); 57 | 58 | &:active, 59 | &:visited { 60 | color: var(--theme-link-color, #B2DC5B); 61 | } 62 | 63 | &:hover { 64 | color: var(--theme-link-color-secondary, #a1cb4a); 65 | } 66 | } 67 | 68 | &.hidden { 69 | opacity: 0; 70 | z-index: -1; 71 | } 72 | } 73 | 74 | @media screen and (min-width: 720px) { 75 | body.limit-size #app { 76 | max-width: 500px; 77 | max-height: 640px; 78 | border: 5px solid rgba(0, 0, 0, 0.2); 79 | } 80 | } 81 | 82 | @media screen and (display-mode: standalone) { 83 | body.limit-size #app { 84 | max-width: 100vw; 85 | max-height: 100vh; 86 | border: none; 87 | } 88 | } 89 | 90 | @media screen and (max-width: 719px) { 91 | .fullscreen-option { 92 | display: none; 93 | } 94 | } -------------------------------------------------------------------------------- /src/styles/_input.scss: -------------------------------------------------------------------------------- 1 | .input { 2 | width: 100%; 3 | height: 26px; 4 | position: relative; 5 | 6 | input { 7 | width: 100%; 8 | height: 26px; 9 | border: 0; 10 | margin: 0; 11 | padding: 4px 4px; 12 | box-sizing: border-box; 13 | background-color: var(--theme-input-background, #121c21); 14 | font-size: 18px; 15 | font-family: monospace; 16 | color: transparent; 17 | caret-color: var(--theme-cursor, #fff); 18 | 19 | &:focus { 20 | outline: none; 21 | } 22 | } 23 | 24 | .extra-info { 25 | position: absolute; 26 | bottom: 26px; 27 | left: 0; 28 | 29 | line-height: 1.25; 30 | width: fit-content; 31 | padding: 4px 6px; 32 | background-color: white; 33 | color: black; 34 | box-sizing: border-box; 35 | user-select: none; 36 | border: 1px solid black; 37 | 38 | p { 39 | margin: 0; 40 | font-size: 12px; 41 | } 42 | } 43 | 44 | .syntax-highlight { 45 | position: absolute; 46 | top: -0.5px; // For some reason 47 | left: 0px; 48 | 49 | width: 100%; 50 | height: 26px; 51 | border: 0; 52 | margin: 0; 53 | padding: 0px 4px; 54 | box-sizing: border-box; 55 | font-size: 18px; 56 | font-family: monospace; 57 | pointer-events: none; 58 | white-space: nowrap; 59 | overflow: hidden; 60 | } 61 | } 62 | 63 | .symbol { 64 | &-litr { 65 | color: var(--theme-symbol-litr, #F38BA8); 66 | } 67 | 68 | &-oper { 69 | color: var(--theme-symbol-oper, #F9E2AF); 70 | } 71 | 72 | &-func { 73 | color: var(--theme-symbol-func, #89B4FA); 74 | } 75 | 76 | &-rbrk, 77 | &-lbrk { 78 | color: var(--theme-symbol-rbrk, #A6ADC8); 79 | } 80 | 81 | &-var { 82 | color: var(--theme-symbol-var, #FAB387); 83 | } 84 | 85 | &-nextparam { 86 | color: var(--theme-symbol-separator, #A6ADC8); 87 | } 88 | 89 | &-unknown { 90 | color: var(--theme-symbol-separator, #A6ADC8); 91 | } 92 | } -------------------------------------------------------------------------------- /src/components/HistoryLine.tsx: -------------------------------------------------------------------------------- 1 | import type { LargeNumber } from "@/math/internal/large-number"; 2 | import syntaxHighlight from "@/math/syntax-highlighter"; 3 | import { CircleAlert } from "lucide-react"; 4 | import type { RefObject } from "preact"; 5 | import { useCallback } from "preact/hooks"; 6 | 7 | export type HistoryLineData = { 8 | expression: string; 9 | answer?: LargeNumber; 10 | latex: boolean; 11 | }; 12 | 13 | export default function HistoryLine({ 14 | expression, 15 | answer, 16 | latex, 17 | inputRef, 18 | accuracy, 19 | }: HistoryLineData & { 20 | inputRef?: RefObject; 21 | accuracy: number; 22 | }) { 23 | const copyAnswer = useCallback(() => { 24 | if (!inputRef?.current) return; 25 | if (!answer) return; 26 | inputRef.current.value += answer 27 | .toSignificantDigits(accuracy) 28 | .toString() 29 | .replace(".", ","); 30 | inputRef.current.dispatchEvent(new Event("input", { bubbles: true })); // Syntax highlight 31 | inputRef.current.focus(); 32 | }, [inputRef?.current, accuracy, answer]); 33 | 34 | return ( 35 |
    36 | {latex && ( 37 |

    38 | LaTeX experimental support 39 |

    40 | )} 41 | {/* biome-ignore lint/security/noDangerouslySetInnerHtml: Internal function */} 42 |

    45 | {syntaxHighlight(expression)} 46 |

    47 | {answer ? ( 48 |

    { 51 | if (event.detail !== 2) return; 52 | event.preventDefault(); 53 | copyAnswer(); 54 | }} 55 | onKeyPress={() => copyAnswer()} 56 | > 57 | {"="} 58 | {answer.toSignificantDigits(accuracy).toString().replace(".", ",")} 59 |

    60 | ) : ( 61 |

    62 | ⠀ 63 |

    64 | )}{" "} 65 | {/* Space taker */} 66 |
    67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/styles/themes/dracula.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on Dracula Speedcrunch theme https://github.com/dracula/speedcrunch 3 | * Licensed under MIT 4 | */ 5 | 6 | /* 7 | MIT License 8 | 9 | Copyright (c) 2022 Dracula Theme 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in all 19 | copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | SOFTWARE. 28 | */ 29 | 30 | body.theme-dracula { 31 | --theme-background: #282a36; 32 | --theme-text-color: #f8f8f2; 33 | --theme-answer: #50fa7b; 34 | --theme-warning: #ff79c6; 35 | --theme-input-background: #44475a; 36 | --theme-cursor: #f8f8f2; 37 | --theme-symbol-litr: #ffb86c; 38 | --theme-symbol-oper: #8be9fd; 39 | --theme-symbol-func: #50fa7b; 40 | --theme-symbol-var: #f8f8f2; 41 | --theme-symbol-separator: #ff79c6; 42 | --theme-symbol-rbrk: #f8f8f2; 43 | --theme-symbol-lbrk: #f8f8f2; 44 | --theme-link-color: #8be9fd; /* Operator color */ 45 | --theme-link-color-secondary: #a4f2ff; /* Even Lighter Operator color */ 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

    2 | 3 |
    kalkki
    4 |

    5 | 6 |
    7 |

    Modern scientific calculator in the browser inspired by SpeedCrunch

    8 |

    Demo

    9 |
    10 | 11 | --- 12 | Kalkki is a modern web-based scientific calculator designed as an replacement for SpeedCrunch. I started developing this because the Finnish matriculation exams are moving from a Linux based environment to a restricted browser one meaning SpeedCrunch wouldn't be available. 13 | 14 | ## Features 15 | 16 | - Familiar terminal style 17 | - Experimental LaTeX pasting support 18 | - Support for user defined variables and functions 19 | - Vast selection of built-in functions 20 | - History (up and down arrow) 21 | - Progressive Web App for offline usage 22 | - Super fast math evaluation powered by [GMP and WASM](https://github.com/Daninet/gmp-wasm) 23 | - Tokenizer based math parser (forked from Abicus) 24 | 25 | ## Usage 26 | 27 | You can do these with your *favorite package manager*. I like [Bun](https://bun.sh), but it isn't required to run this. 28 | 29 | 1. `bun install` 30 | 2. `bun run dev` 31 | 32 | Tada! You have a dev server running on :5173. Consult package.json 'scripts' for more information. 33 | 34 | The project uses [Biome](https://biomejs.dev/) for linting and code formatting. Make sure to `bun run check` your code before submitting a PR. We use `husky` for pre-commit checks. 35 | 36 | ## TODO 37 | 38 | - Autocorrect / suggestions 39 | - Unit conversion 40 | - Make the LaTeX support more stable 41 | - Check the AI-generated Swedish translation 42 | - Add bouncing(?) and some sort of job queue -> same expression (for suggestion and calc) could wait for the original one 43 | 44 | ## License 45 | 46 | Licensed under the AGPLv3 license, consult LICENSE for more information. 47 | 48 | ### Credits 49 | 50 | - Logo icon: Tabler Icons math-function-x, licensed under MIT, see [license](https://tabler.io/license). 51 | - [Abicus](https://github.com/digabi/abicus) for the math engine, licensed under MIT, see [license](https://github.com/digabi/abicus/blob/master/LICENCE.md) or src/math/LICENSE 52 | -------------------------------------------------------------------------------- /src/components/AutoUpdate.tsx: -------------------------------------------------------------------------------- 1 | import { type Language, translate } from "@/lang"; 2 | import { useCallback, useEffect, useState } from "preact/hooks"; 3 | 4 | const GIT_HASH = import.meta.env.VITE_GIT_COMMIT; 5 | 6 | /** 7 | * The web app might be cached, so users don't get the latest version 8 | */ 9 | export function AutoUpdate({ language }: { language: Language }) { 10 | const [updated, setUpdated] = useState(false); 11 | useEffect(() => { 12 | let fetchLoop: Timer | null = null; 13 | async function fetchVersion() { 14 | try { 15 | const res = await fetch("/manifest.json", { 16 | cache: "no-store", 17 | }); 18 | const json = await res.json(); 19 | if (!json.gitHash || json.gitHash.length !== 7) return; // Invalid hash/response 20 | if (json.gitHash === GIT_HASH) return; 21 | 22 | // It's updated 23 | setUpdated(true); 24 | if (fetchLoop) clearInterval(fetchLoop); 25 | } catch (e) { 26 | // Likely development environment 27 | } 28 | } 29 | fetchVersion(); 30 | fetchLoop = setInterval(fetchVersion, 5 * 60 * 1000); // Some may keep the tab open for long time periods... 31 | }, []); 32 | 33 | const update = useCallback(() => { 34 | if ("caches" in window) { 35 | caches.keys().then((names) => { 36 | for (const name of names) { 37 | caches.delete(name); 38 | } 39 | }); 40 | } 41 | if ("serviceWorker" in navigator) { 42 | navigator.serviceWorker.getRegistrations().then((registrations) => { 43 | for (const registration of registrations) { 44 | registration.update(); 45 | } 46 | }); 47 | } 48 | window.location.reload(); 49 | }, []); 50 | 51 | return updated ? ( 52 |
    53 |
    54 |

    55 | {translate("updateAvailable", language)} 56 |

    57 |

    58 | {translate("updateAvailableDescription", language)} 59 |

    60 |
    61 | 68 |
    69 |
    70 |
    71 | ) : null; 72 | } 73 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import preact from "@preact/preset-vite"; 3 | import { defineConfig } from "vite"; 4 | import { VitePWA } from "vite-plugin-pwa"; 5 | 6 | import { execSync } from "child_process"; 7 | import { readFileSync, writeFileSync } from "fs"; 8 | const commitHash = execSync("git rev-parse --short HEAD").toString().trim(); 9 | const packageJson = JSON.parse( 10 | readFileSync("./package.json").toString("utf-8"), 11 | ); 12 | 13 | process.env.VITE_GIT_COMMIT = commitHash; 14 | process.env.VITE_APP_VERSION = packageJson.version; 15 | 16 | // https://vite.dev/config/ 17 | export default defineConfig({ 18 | server: { 19 | strictPort: true, 20 | 21 | watch: { 22 | // tell vite to ignore watching `src-tauri` 23 | ignored: ['**/src-tauri/**'], 24 | }, 25 | }, 26 | plugins: [ 27 | preact(), 28 | VitePWA({ 29 | registerType: "autoUpdate", 30 | includeAssets: ["favicon.ico", "apple-touch-icon.png", "mask-icon.svg"], 31 | manifest: { 32 | name: "Kalkki", 33 | short_name: "Kalkki", 34 | description: "Selainpohjainen tieteislaskin", 35 | theme_color: "#1D2D35", 36 | icons: [ 37 | { 38 | src: "pwa-192x192.png", 39 | sizes: "192x192", 40 | type: "image/png", 41 | }, 42 | { 43 | src: "pwa-512x512.png", 44 | sizes: "512x512", 45 | type: "image/png", 46 | }, 47 | ], 48 | lang: "fi", 49 | }, 50 | workbox: { 51 | navigateFallbackDenylist: [/^\/manifest\.json$/, /^\/third-party-licenses\.txt/] 52 | } 53 | }), 54 | { 55 | name: "generate-manifest", 56 | apply: "build", 57 | closeBundle() { 58 | // Manifest data 59 | const manifest = { 60 | version: packageJson.version, 61 | gitHash: commitHash, 62 | buildDate: new Date().toISOString(), 63 | }; 64 | 65 | // Write manifest.json to the dist directory 66 | const distPath = resolve("dist", "manifest.json"); 67 | writeFileSync(distPath, JSON.stringify(manifest, null, 2)); 68 | 69 | console.log("✅ Manifest generated:", distPath); 70 | }, 71 | }, 72 | ], 73 | build: { 74 | rollupOptions: { 75 | input: { 76 | main: resolve(__dirname, "index.html"), 77 | }, 78 | }, 79 | }, 80 | resolve: { 81 | alias: { 82 | "@": resolve(__dirname, "./src"), 83 | }, 84 | }, 85 | }); 86 | -------------------------------------------------------------------------------- /src/test/speedcrunch.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { RESERVED_VARIABLES } from "@/math/internal/evaluator"; 3 | 4 | // Here's a list of all SpeedCrunch functions 5 | const speedCrunchFunctions = [ 6 | "abs", 7 | "absdev", 8 | // "and", // Binary/bitwise not planned 9 | "arccos", 10 | "arcosh", 11 | "arcsin", 12 | "arctan", 13 | "arctan2", 14 | "arsinh", 15 | "artanh", 16 | "average", 17 | // "bin", // Binary not supported 18 | "binomcdf", 19 | "binommean", 20 | "binompmf", 21 | "binomvar", 22 | // "cart", // Complex numbers not supported 23 | "cbrt", 24 | "ceil", 25 | "cos", 26 | "cosh", 27 | "cot", 28 | "csc", 29 | // "dec", // Non dec not supported 30 | "degrees", 31 | "erf", 32 | "erfc", 33 | "exp", 34 | "floor", 35 | "frac", 36 | "gamma", 37 | "gcd", 38 | "geomean", 39 | // "hex", // Hexadecimals not supported 40 | "hypercdf", 41 | "hypermean", 42 | "hyperpmf", 43 | "hypervar", 44 | "idiv", 45 | /*"ieee754_decode", // These are highly tech stuff, no one is going to use these realistically in an exam 46 | "ieee754_double_decode", 47 | "ieee754_double_encode", 48 | "ieee754_encode", 49 | "ieee754_half_decode", 50 | "ieee754_half_encode", 51 | "ieee754_quad_decode", 52 | "ieee754_quad_encode", 53 | "ieee754_single_decode", 54 | "ieee754_single_encode",*/ 55 | // "imag", // Complex numbers not supported 56 | "int", 57 | "lb", 58 | "lg", 59 | "ln", 60 | "lngamma", 61 | "log", 62 | // "mask", // Binary/bitwise not planned 63 | "max", 64 | "median", 65 | "min", 66 | "mod", 67 | "ncr", 68 | // "not", // Binary/bitwise not planned 69 | "npr", 70 | // "oct", // Octal not supported 71 | // "or", // Binary/bitwise not planned 72 | // "phase", // Complex numbers not supported 73 | "poicdf", 74 | "poimean", 75 | "poipmf", 76 | "poivar", 77 | //"polar", // Complex numbers not supported 78 | "product", 79 | "radians", 80 | // "real", // Complex numbers not supported 81 | "round", 82 | "sec", 83 | "sgn", 84 | // "shl", // Bitwise/binary not planned 85 | // "shr", 86 | "sin", 87 | "sinh", 88 | "sqrt", 89 | "stddev", 90 | "sum", 91 | "tan", 92 | "tanh", 93 | "trunc", 94 | // "unmask", // Binary/bitwise not planned 95 | "variance", 96 | // "xor", // Binary/bitwise not planned 97 | ]; 98 | 99 | for (const func of speedCrunchFunctions) { 100 | test(`${func}()`, () => { 101 | const find = RESERVED_VARIABLES.find((i) => i === func); 102 | // Doing this so the output isn't filled with the entire array 103 | expect(find).not.toBeUndefined(); 104 | }); 105 | } 106 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: 'publish' 2 | 3 | on: 4 | push: 5 | tags: ['v0.*'] 6 | 7 | # This workflow will trigger on each push to the `release` branch to create or update a GitHub release, build your app, and upload the artifacts to the release. 8 | 9 | jobs: 10 | publish-tauri: 11 | permissions: 12 | contents: write 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - platform: 'macos-latest' # for Arm based macs (M1 and above). 18 | args: '--target aarch64-apple-darwin' 19 | - platform: 'macos-latest' # for Intel based macs. 20 | args: '--target x86_64-apple-darwin' 21 | - platform: 'ubuntu-22.04' # for Tauri v1 you could replace this with ubuntu-20.04. 22 | args: '' 23 | - platform: 'windows-latest' 24 | args: '' 25 | 26 | runs-on: ${{ matrix.platform }} 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: oven-sh/setup-bun@v2 31 | with: 32 | bun-version: latest 33 | 34 | - name: install Rust stable 35 | uses: dtolnay/rust-toolchain@stable 36 | with: 37 | # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds. 38 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 39 | 40 | - name: install dependencies (ubuntu only) 41 | if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above. 42 | run: | 43 | sudo apt-get update 44 | sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 45 | # webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2. 46 | # You can remove the one that doesn't apply to your app to speed up the workflow a bit. 47 | 48 | - name: install frontend dependencies 49 | run: bun install # change this to npm, pnpm or bun depending on which one you use. 50 | 51 | - uses: tauri-apps/tauri-action@v0 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | with: 55 | tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version. 56 | releaseName: 'Kalkki v__VERSION__ Binaries' 57 | releaseBody: '
    You will find the native binaries for the Kalkki application below.
    **For Apple computers with Apple Silicon (eg. M1) you need the aarch64 binary.** Apple computers with Intel processors need the x64 one.' 58 | releaseDraft: true 59 | prerelease: false 60 | args: ${{ matrix.args }} -------------------------------------------------------------------------------- /src/util/licenses.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "@tauri-apps/plugin-opener", 4 | "author": "n/a", 5 | "licenseType": "MIT OR Apache-2.0", 6 | "link": "git+https://github.com/tauri-apps/plugins-workspace.git" 7 | }, 8 | { 9 | "name": "gmp-wasm", 10 | "author": "Dani Biró (https://danibiro.com)", 11 | "licenseType": "LGPL-3.0-only", 12 | "link": "git+https://github.com/Daninet/gmp-wasm.git" 13 | }, 14 | { 15 | "name": "latex-utensils", 16 | "author": "n/a", 17 | "licenseType": "MIT", 18 | "link": "git+https://github.com/tamuratak/latex-utensils.git" 19 | }, 20 | { 21 | "name": "lucide-react", 22 | "author": "Eric Fennis", 23 | "licenseType": "ISC", 24 | "link": "git+https://github.com/lucide-icons/lucide.git" 25 | }, 26 | { 27 | "name": "neverthrow", 28 | "author": "Giorgio Delgado", 29 | "licenseType": "MIT", 30 | "link": "git+https://github.com/supermacro/neverthrow.git" 31 | }, 32 | { 33 | "name": "preact", 34 | "author": "n/a", 35 | "licenseType": "MIT", 36 | "link": "git+https://github.com/preactjs/preact.git" 37 | }, 38 | { 39 | "name": "ts-pattern", 40 | "author": "Gabriel Vergnaud", 41 | "licenseType": "MIT", 42 | "link": "git+ssh://git@github.com/gvergnaud/ts-pattern.git" 43 | }, 44 | { 45 | "name": "@biomejs/biome", 46 | "author": "Emanuele Stoppa", 47 | "licenseType": "MIT OR Apache-2.0", 48 | "link": "git+https://github.com/biomejs/biome.git" 49 | }, 50 | { 51 | "name": "@preact/preset-vite", 52 | "author": "The Preact Team (https://preactjs.com)", 53 | "licenseType": "MIT", 54 | "link": "git+https://github.com/preactjs/preset-vite.git" 55 | }, 56 | { 57 | "name": "@tauri-apps/cli", 58 | "author": "n/a", 59 | "licenseType": "Apache-2.0 OR MIT", 60 | "link": "git+https://github.com/tauri-apps/tauri.git" 61 | }, 62 | { 63 | "name": "@types/bun", 64 | "author": "n/a", 65 | "licenseType": "MIT", 66 | "link": "https://github.com/DefinitelyTyped/DefinitelyTyped.git" 67 | }, 68 | { 69 | "name": "husky", 70 | "author": "typicode", 71 | "licenseType": "MIT", 72 | "link": "git+https://github.com/typicode/husky.git" 73 | }, 74 | { 75 | "name": "sass-embedded", 76 | "author": "Google Inc.", 77 | "licenseType": "MIT", 78 | "link": "git+https://github.com/sass/embedded-host-node.git" 79 | }, 80 | { 81 | "name": "typescript", 82 | "author": "Microsoft Corp.", 83 | "licenseType": "Apache-2.0", 84 | "link": "git+https://github.com/microsoft/TypeScript.git" 85 | }, 86 | { 87 | "name": "vite", 88 | "author": "Evan You", 89 | "licenseType": "MIT", 90 | "link": "git+https://github.com/vitejs/vite.git" 91 | }, 92 | { 93 | "name": "vite-plugin-pwa", 94 | "author": "antfu ", 95 | "licenseType": "MIT", 96 | "link": "git+https://github.com/vite-pwa/vite-plugin-pwa.git" 97 | } 98 | ] 99 | -------------------------------------------------------------------------------- /src/styles/_page.scss: -------------------------------------------------------------------------------- 1 | // Pages are "ovelays" that show text based content 2 | .page-overlay { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | width: 100vw; 7 | height: 100vh; 8 | overflow-y: auto; 9 | overflow-x: hidden; 10 | display: flex; 11 | justify-content: center; 12 | z-index: 10000; 13 | background-color: #1d2d35; 14 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Cg fill-rule='evenodd'%3E%3Cg fill='%23162228' fill-opacity='0.4'%3E%3Cpath d='M0 38.59l2.83-2.83 1.41 1.41L1.41 40H0v-1.41zM0 1.4l2.83 2.83 1.41-1.41L1.41 0H0v1.41zM38.59 40l-2.83-2.83 1.41-1.41L40 38.59V40h-1.41zM40 1.41l-2.83 2.83-1.41-1.41L38.59 0H40v1.41zM20 18.6l2.83-2.83 1.41 1.41L21.41 20l2.83 2.83-1.41 1.41L20 21.41l-2.83 2.83-1.41-1.41L18.59 20l-2.83-2.83 1.41-1.41L20 18.59z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); 15 | color: white; 16 | 17 | transition: all 0.25s ease-in-out; 18 | 19 | transform: translateY(100%); 20 | opacity: 0; 21 | 22 | &.visible { 23 | transform: translateY(0); 24 | opacity: 1; 25 | } 26 | } 27 | 28 | @supports (max-height: 100dvh) { 29 | .page-overlay { 30 | max-height: 100dvh; 31 | } 32 | } 33 | 34 | .page-overlay .close { 35 | position: fixed; 36 | appearance: none; 37 | margin: 0; 38 | border: none; 39 | background: none; 40 | 41 | position: absolute; 42 | top: 1.5rem; 43 | right: 1.5rem; 44 | 45 | opacity: 0.5; 46 | transition: opacity 0.3s cubic-bezier(.25, .46, .45, .94); 47 | 48 | &:hover { 49 | opacity: 1; 50 | cursor: pointer; 51 | } 52 | } 53 | 54 | .page-overlay .content { 55 | display: flex; 56 | flex-direction: column; 57 | align-items: center; 58 | gap: 0.5rem; 59 | max-width: min(450px, calc(100vw - 2rem)); 60 | text-align: center; 61 | height: max-content; 62 | justify-content: center; 63 | min-height: fit-content; 64 | margin: 4rem 0; // Some breathing space 65 | 66 | h1, 67 | h2, 68 | p { 69 | margin: 0; 70 | } 71 | 72 | svg { 73 | flex: none; 74 | color: #B2DC5B; 75 | } 76 | 77 | .main-content { 78 | display: flex; 79 | flex-direction: column; 80 | gap: 0.75rem; 81 | text-align: left; 82 | margin-top: 1.5rem; 83 | width: 100%; 84 | } 85 | 86 | a { 87 | color: #B2DC5B; 88 | 89 | &:hover { 90 | color: #a1cb4a; 91 | } 92 | 93 | &:active, 94 | &:visited { 95 | color: #B2DC5B; 96 | } 97 | } 98 | } 99 | 100 | .copyright-page { 101 | code { 102 | text-align: justify; 103 | margin: 1.5rem 0; 104 | } 105 | 106 | ul { 107 | padding-left: 0; 108 | margin: 0; 109 | } 110 | 111 | h3 { 112 | margin: 0.5rem 0; 113 | } 114 | 115 | li { 116 | white-space: initial; 117 | word-wrap: break-word 118 | } 119 | } -------------------------------------------------------------------------------- /src/styles/_top-bar.scss: -------------------------------------------------------------------------------- 1 | .top-bar { 2 | height: 30px; 3 | background-color: rgb(250, 250, 250); 4 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 5 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 6 | user-select: none; 7 | position: relative; 8 | z-index: 1000; 9 | flex-shrink: 0; 10 | } 11 | 12 | .menu-bar { 13 | height: 100%; 14 | display: flex; 15 | align-items: center; 16 | list-style: none; 17 | padding: 0; 18 | margin: 0; 19 | } 20 | .menu-item { 21 | position: relative; 22 | height: 100%; 23 | display: flex; 24 | align-items: center; 25 | } 26 | .menu-item > span { 27 | padding: 0 12px; 28 | font-size: 13px; 29 | color: rgb(50, 50, 50); 30 | cursor: default; 31 | height: 100%; 32 | display: flex; 33 | align-items: center; 34 | } 35 | .menu-item:hover > span { 36 | background-color: rgba(0, 0, 0, 0.05); 37 | } 38 | .dropdown, .submenu { 39 | position: absolute; 40 | top: 100%; 41 | left: 0; 42 | min-width: 200px; 43 | background: rgb(255, 255, 255); 44 | opacity: 0; 45 | display: none; 46 | transform: translateY(-4px); 47 | transition: all 0.2s ease; 48 | border: 1px solid rgba(0, 0, 0, 0.1); 49 | padding: 0px; 50 | } 51 | .menu-item:hover > .dropdown, 52 | .has-submenu:hover > .submenu { 53 | opacity: 1; 54 | display: block; 55 | transform: translateY(0); 56 | } 57 | .submenu { 58 | left: 100%; 59 | top: -1px; // Border 60 | z-index: 10; // Avoid overlap with the other buttons on the edge 61 | 62 | /* TODO: make it responsive/scrollable overflow-x: hidden; 63 | overflow-y: auto; 64 | scrollbar-width: thin;*/ 65 | } 66 | .dropdown button, 67 | .dropdown span, 68 | .dropdown a { 69 | width: 100%; 70 | text-align: left; 71 | padding: 6px 12px; 72 | border: none; 73 | background: none; 74 | font-size: 13px; 75 | color: rgb(50, 50, 50); 76 | cursor: default; 77 | display: flex; 78 | gap: 0.4rem; 79 | align-items: center; 80 | font-family: inherit; 81 | line-height: 1.5; 82 | box-sizing: border-box; 83 | } 84 | 85 | .dropdown a, 86 | .dropdown button { 87 | appearance: none; 88 | text-decoration: none; 89 | 90 | &:hover { 91 | cursor: pointer; 92 | } 93 | } 94 | 95 | .has-submenu { 96 | position: relative; 97 | } 98 | .has-submenu > span::after { 99 | content: '›'; 100 | position: absolute; 101 | right: 10px; 102 | font-size: 14px; 103 | } 104 | .dropdown button:hover, 105 | .dropdown span:hover, 106 | .dropdown a:hover { 107 | background-color: rgba(0, 0, 0, 0.05); 108 | } 109 | /* Focus styles for keyboard navigation */ 110 | .dropdown button:focus, 111 | .dropdown span:focus, 112 | .dropdown a:focus { 113 | outline: 2px solid rgb(0, 120, 215); 114 | outline-offset: -2px; 115 | background-color: rgba(0, 0, 0, 0.05); 116 | } 117 | 118 | ul { 119 | list-style: none; 120 | } 121 | 122 | li { 123 | white-space: nowrap; 124 | } 125 | 126 | .dropdown button.destructive { 127 | color: oklch(0.444 0.177 26.899); 128 | } -------------------------------------------------------------------------------- /src/lang/jp.ts: -------------------------------------------------------------------------------- 1 | import type { fi } from "./fi"; 2 | 3 | export const jp: typeof fi = { 4 | localeName: "日本語", 5 | 6 | /** Welcome message */ 7 | welcome: "Kalkkiへようこそ – 使いやすくて高速な関数電卓!", 8 | welcomeStart: "まずは、下のフィールドに式を入力してください。", 9 | welcomePwaPrompt: 10 | '