diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f45581..9b47be2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,8 +13,17 @@ Thanks for your interest in contributing! - One feature or fix per PR - `cargo clippy -- -D warnings` and `npm run build` should pass +- `npm run i18n:check` should pass for locale changes - Use [conventional commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, `chore:`) +## Translations + +- Source locale is `src/i18n/locales/en.json` +- Add/update translated keys in `src/i18n/locales/.json` +- New locale files are auto-discovered in-app (including Settings language dropdown); no code changes needed +- Keep placeholders aligned with English (`{count}`, `{name}`, plural vars) +- Validate with `npm run i18n:check` + ## Issues Found a bug? [Open an issue](https://github.com/w0nk1/StepCast/issues) with your macOS version and steps to reproduce. diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 5169374..a8f0dde 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -310,3 +310,66 @@ 2026-02-15 - Confirmed publish run `22037041331` succeeded and produced both arch assets for draft `v0.3.0`, but release metadata remained tied to prior dispatch context (`targetCommitish` mismatch). - Chose clean provenance path: prepare fresh version/tag `0.3.1` from current `main`. + +2026-02-16 +- Loaded superpowers skill docs: using-superpowers, brainstorming, writing-plans, executing-plans, test-driven-development, verification-before-completion, requesting-code-review. +- Chosen execution default: implement multilingual MVP now (frontend + settings + contributor file schema), defer backend export/tray and Swift AI localization to planned next phases. +- Started skill-driven flow with explicit plan tracking and docs-first artifacts. +- Implemented frontend i18n MVP: added `src/i18n` runtime, locale catalogs (`en`,`de`), and app/editor providers in `src/main.tsx` + `src/editor.tsx`. +- Added Settings language selector with Tauri sync event (`language-changed`) and editor listener. +- Migrated key UI components to i18n keys: Settings, RecorderPanel, ExportSheet, WelcomeBanner, EditorWindow, EditorStepCard, StepItem, CropEditorModal, StepScreenshot, ImageLightbox, UndoToast. +- Added contributor translation validator `scripts/check-i18n.js`, npm script `i18n:check`, and contributing docs update. +- Verified: `npm run i18n:check`, `npm test` (254/254), `npm run build`. +- Ran requesting-code-review flow with explorer subagent; finding: test isolation risk in `SettingsSheet.test.tsx` due persisted `appLanguage`. +- Applied fix: clear `localStorage` and reset `data-theme` in test `beforeEach`; re-verified full frontend gate. +2026-02-16 +- Began Phase 2 backend i18n implementation for multilingual support (export + tray) based on prior Phase 1 frontend rollout. +- Added new Rust locale module `src-tauri/src/i18n.rs` with app-language parsing, locale resolution, translated export/tray strings, and unit tests. +- Extended `export_guide` command in `src-tauri/src/lib.rs` to accept optional `app_language` and pass resolved locale into export pipeline. +- Localized export pipeline: + - `src-tauri/src/export/mod.rs` now routes locale through all formats. + - `src-tauri/src/export/helpers.rs` adds localized action/effective descriptions and auth-placeholder localization guard. + - `src-tauri/src/export/html.rs` and `src-tauri/src/export/markdown.rs` render localized step count/headings/alt text. + - `src-tauri/src/export/pdf.rs` now renders PDF HTML with locale-aware strings. +- Localized tray labels/tooltips in `src-tauri/src/tray.rs` using system locale helpers. +- Updated frontend export invoke in `src/components/RecorderPanel.tsx` to include language and pass resolved locale (`en|de`) when setting is `system`. +- Updated export payload expectation in `src/components/RecorderPanel.test.tsx`. +- Ran independent review subagent; key finding was macOS `system` locale fallback risk. +- Applied review-driven fix: + - Frontend now sends resolved locale for export instead of raw `system`. + - Backend locale detection hardened to read macOS `AppleLocale` + cache result. +- Verification completed: + - `cd src-tauri && cargo test --quiet` + - `npm run i18n:check` + - `npm test` + - `npm run build` +2026-02-16 +- Started Phase 3 Apple Intelligence i18n work with spec `docs/specs/2026-02-16-apple-intelligence-i18n-phase3.md`. +- Extended Rust AI bridge: + - `src-tauri/src/lib.rs`: `generate_step_descriptions` now accepts optional `app_language` and resolves locale. + - `src-tauri/src/apple_intelligence/mod.rs`: `GenerateRequest` now includes `app_language`; generation forwards locale (`en|de`) to Swift helper. +- Extended frontend AI invoke payloads: + - `src/components/RecorderPanel.tsx`: stop-triggered `generate_step_descriptions` now sends resolved language. + - `src/components/EditorWindow.tsx`: single-step, bulk, and enhance-all generation now send resolved language. +- Localized Swift helper outputs: + - `src-tauri/swift/stepcast_ai_helper.swift`: locale plumbing (`appLanguage`), localized instructions/fallback errors, optional `availability --lang` support. + - `src-tauri/swift/stepcast_ai_helper_descriptions.swift`: localized baseline sentence templates, prompt text, verb detection, and quality-gate locale-aware checks. +- Review pass (requesting-code-review style) completed; implemented follow-up fixes: + - localized availability details path (`--lang`) for helper availability command, + - hardened `language-changed` listener cleanup in `EditorWindow`. +- Added regression coverage in `src/components/EditorWindow.test.tsx` verifying `language-changed -> appLanguage: "de"` in AI generation invoke payload. +- Verification completed: + - `swiftc -typecheck src-tauri/swift/stepcast_ai_helper.swift src-tauri/swift/stepcast_ai_helper_descriptions.swift src-tauri/swift/stepcast_ai_helper_vision.swift` + - `cd src-tauri && cargo test --quiet` + - `npm test` (255 tests) + - `npm run i18n:check` + - `npm run build` +2026-02-16 +- Fixed stopped-state action bar overflow in localized UI: `src/App.css` now uses a strict 3-column grid (`minmax(0,1fr)`) and `src/components/RecorderPanel.tsx` wraps action button labels in `.action-label` for safe truncation. +- Verification: `npm test` (255/255) and `npm run build` pass. +2026-02-16 +- Implemented dynamic-language UX scaling: `src/components/SettingsSheet.tsx` now renders a language dropdown backed by i18n `availableLocales` + `getLanguageLabel` instead of hardcoded `en/de` pills. +- Hardened language event sync for future locales in `src/components/EditorWindow.tsx` via `isSupportedAppLanguage`. +- Added select styling in `src/App.css` and adjusted settings tests to combobox interaction in `src/components/SettingsSheet.test.tsx`. +- Updated contributor docs in `CONTRIBUTING.md` to document auto-discovery of new locale JSON files. +- Verification: `npm test` (255/255), `npm run i18n:check`, `npm run build`. diff --git a/docs/choices.md b/docs/choices.md index 5c73eee..0da5fe5 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -242,3 +242,29 @@ 2026-02-15 (release-provenance-clean-cut) - Default: cut `v0.3.1` from latest `main` instead of publishing `v0.3.0` draft produced via workflow_dispatch commit mismatch; reason: keep tag, source, and artifacts strictly aligned. + +2026-02-16 (multilanguage-mvp-kickoff) +- Default: execute multilingual support in phased rollout; Phase 1 = frontend i18n infrastructure + language selector + key UI migration, Phase 2 = export/tray/backend strings, Phase 3 = Swift Apple-Intelligence prompt/baseline localization; reason: deliver contributor-ready value quickly without risky cross-stack big-bang. +- Default: keep English as canonical source locale (`en`) and require all additional locales to mirror key structure; reason: deterministic fallback + safe contributor workflow. +- Default: do not block on branch/worktree setup in this run; reason: user explicitly requested immediate brainstorming -> plan -> execution in current session. +- Default: keep i18n runtime lightweight and in-repo (`src/i18n`) instead of adding external i18n dependency in MVP; reason: lower migration risk and simpler contributor onboarding while preserving key-based catalogs. +- Default: use `appLanguage` (`system|en|de`) persisted in localStorage and synchronized across webviews via Tauri `language-changed` event; reason: mirrors existing `ai-toggle-changed` reliability pattern in Tauri multi-webview setups. +- Default: migrate high-frequency frontend surfaces first (panel/settings/editor/export) and keep Rust export/tray + Swift AI localization as planned follow-up phases; reason: fast user-visible impact with controlled scope. + +2026-02-16 (multilanguage-phase2-backend) +- Default: export command receives resolved locale (`en|de`) from frontend even when UI setting is `system`; reason: avoid macOS GUI env-var locale gaps and keep export language consistent with visible UI language. +- Default: backend system locale detection now checks macOS `AppleLocale` (`defaults read -g AppleLocale`) with env fallback and one-time caching; reason: tray localization should remain correct in packaged app launches where `LANG` is often unset. +- Default: keep tray language derived from system locale (not app setting) for now; reason: Phase 2 scope targets startup/system consistency and avoids runtime tray relabel complexity. +- Default: auth placeholder export text is localized only when step description is empty or matches known default placeholder texts; reason: preserve user-authored/custom descriptions. + +2026-02-16 (multilanguage-phase3-apple-intelligence) +- Default: `generate_step_descriptions` receives `appLanguage` from frontend and forwards it to the Swift helper request as `app_language`; reason: AI output language must follow in-app language selection, not only system locale. +- Default: keep AI heuristics/classification on canonical internal verbs/kinds and localize only rendered baseline/prompt/verb checks; reason: reduce regression risk in existing quality-gate logic while adding localized output. +- Default: localize helper `availability` details via optional `--lang` argument (system locale from Rust caller); reason: keep fallback/error text language-consistent when model/framework is unavailable. +- Default: add listener cleanup hardening (`cancelled` pattern) for editor `language-changed` event; reason: avoid stale unlisten handles in async listener registration races. +- Default: stopped-state action bar uses fixed 3-column grid with `minmax(0, 1fr)` and label ellipsis; reason: prevent localized button labels from forcing horizontal overflow. + +2026-02-16 (multilanguage-phase4-dynamic-locale-selector) +- Default: language selection UI uses a dropdown (`` with options: `system` + discovered locales. +- Validate event payloads with `isSupportedAppLanguage` in `EditorWindow` listener. +- Keep English fallback behavior unchanged in i18n core. + +## Verification +- `npm test` +- `npm run i18n:check` +- `npm run build` diff --git a/package.json b/package.json index 39a5de1..82fc6d1 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "preview": "vite preview", "tauri": "tauri", "session:ai-report": "node scripts/session-ai-report.js", + "i18n:check": "node scripts/check-i18n.js", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/scripts/check-i18n.js b/scripts/check-i18n.js new file mode 100644 index 0000000..473eb18 --- /dev/null +++ b/scripts/check-i18n.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const localesDir = path.resolve(__dirname, "../src/i18n/locales"); +const sourceFile = path.join(localesDir, "en.json"); + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function extractPlaceholders(input) { + const text = String(input ?? ""); + const names = new Set(); + const pluralPattern = + /\{(\w+),\s*plural,\s*one\s*\{[^{}]*\}\s*other\s*\{[^{}]*\}\s*\}/g; + + for (const match of text.matchAll(pluralPattern)) { + names.add(match[1]); + } + const withoutPlurals = text.replace(pluralPattern, ""); + for (const match of withoutPlurals.matchAll(/\{(\w+)\}/g)) { + names.add(match[1]); + } + + return [...names].sort(); +} + +const source = readJson(sourceFile); +const sourceKeys = Object.keys(source).sort(); +const localeFiles = readdirSync(localesDir) + .filter((name) => name.endsWith(".json") && name !== "en.json") + .sort(); + +let hasIssues = false; + +for (const localeFile of localeFiles) { + const localePath = path.join(localesDir, localeFile); + const localeCode = localeFile.replace(/\.json$/, ""); + const locale = readJson(localePath); + const localeKeys = Object.keys(locale).sort(); + + const missing = sourceKeys.filter((k) => !(k in locale)); + const extra = localeKeys.filter((k) => !(k in source)); + + if (missing.length > 0) { + hasIssues = true; + console.error(`[i18n] ${localeCode}: missing keys (${missing.length})`); + for (const key of missing) console.error(` - ${key}`); + } + if (extra.length > 0) { + hasIssues = true; + console.error(`[i18n] ${localeCode}: extra keys (${extra.length})`); + for (const key of extra) console.error(` - ${key}`); + } + + for (const key of sourceKeys) { + if (!(key in locale)) continue; + const sourceVars = extractPlaceholders(source[key]); + const localeVars = extractPlaceholders(locale[key]); + if (sourceVars.join("|") !== localeVars.join("|")) { + hasIssues = true; + console.error(`[i18n] ${localeCode}: placeholder mismatch for key "${key}"`); + console.error(` source: [${sourceVars.join(", ")}]`); + console.error(` locale: [${localeVars.join(", ")}]`); + } + } +} + +if (hasIssues) { + process.exit(1); +} + +console.log( + `[i18n] OK: ${sourceKeys.length} keys checked across ${localeFiles.length} locale(s).`, +); diff --git a/src-tauri/build.rs b/src-tauri/build.rs index f1e86ae..baa7b87 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -142,7 +142,9 @@ fn build_swift_ai_helper() { .arg("runtime") .arg(&resource_dst) .status() - .unwrap_or_else(|e| panic!("failed to run codesign for {}: {e}", resource_dst.display())); + .unwrap_or_else(|e| { + panic!("failed to run codesign for {}: {e}", resource_dst.display()) + }); if !status.success() { panic!( diff --git a/src-tauri/src/apple_intelligence/mod.rs b/src-tauri/src/apple_intelligence/mod.rs index 73b518b..8999773 100644 --- a/src-tauri/src/apple_intelligence/mod.rs +++ b/src-tauri/src/apple_intelligence/mod.rs @@ -1,3 +1,4 @@ +use crate::i18n::Locale; use crate::recorder::types::Step; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -17,6 +18,8 @@ pub struct GenerateRequest { pub steps: Vec, #[serde(default)] pub max_chars: Option, + #[serde(default)] + pub app_language: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -127,19 +130,32 @@ fn run_helper(_args: &[&str], _stdin: Option<&[u8]>) -> Result, String> Err("not supported on this platform".into()) } -pub fn availability() -> Result { - let out = run_helper(&["availability"], None)?; +pub fn availability(locale: Option) -> Result { + let mut args = vec!["availability"]; + if let Some(locale) = locale { + args.push("--lang"); + args.push(match locale { + Locale::En => "en", + Locale::De => "de", + }); + } + let out = run_helper(&args, None)?; serde_json::from_slice(&out).map_err(|e| format!("parse availability json: {e}")) } pub fn generate_descriptions( steps: Vec, max_chars: usize, + locale: Locale, ) -> Result { // Keep the Swift helper API stable: snake_case JSON. let req = GenerateRequest { steps, max_chars: Some(max_chars), + app_language: Some(match locale { + Locale::En => "en".to_string(), + Locale::De => "de".to_string(), + }), }; let input = serde_json::to_vec(&req).map_err(|e| format!("encode generate json: {e}"))?; let out = run_helper(&["generate"], Some(&input))?; diff --git a/src-tauri/src/export/helpers.rs b/src-tauri/src/export/helpers.rs index dac9f43..7af27a8 100644 --- a/src-tauri/src/export/helpers.rs +++ b/src-tauri/src/export/helpers.rs @@ -1,3 +1,4 @@ +use crate::i18n::Locale; use crate::recorder::types::{ActionType, BoundsPercent, Step}; use base64::Engine; use std::fs; @@ -103,19 +104,25 @@ pub fn marker_position_percent(step: &Step) -> Option<(f32, f32)> { } /// Human-readable description of what happened in a step +#[allow(dead_code)] pub fn action_description(step: &Step) -> String { + action_description_localized(step, Locale::En) +} + +/// Localized human-readable description of what happened in a step. +pub fn action_description_localized(step: &Step, locale: Locale) -> String { if is_auth_placeholder(step) { - return "Authenticate with Touch ID or enter your password to continue.".to_string(); + return crate::i18n::auth_placeholder_description(locale).to_string(); } match step.action { - ActionType::Note => "Note".to_string(), + ActionType::Note => crate::i18n::step_action_note(locale).to_string(), _ => { let verb = match step.action { - ActionType::DoubleClick => "Double-clicked in", - ActionType::RightClick => "Right-clicked in", - ActionType::Shortcut => "Used keyboard shortcut in", - _ => "Clicked in", + ActionType::DoubleClick => crate::i18n::step_action_double_clicked_in(locale), + ActionType::RightClick => crate::i18n::step_action_right_clicked_in(locale), + ActionType::Shortcut => crate::i18n::step_action_shortcut_in(locale), + _ => crate::i18n::step_action_clicked_in(locale), }; format!("{} {} \u{2014} \"{}\"", verb, step.app, step.window_title) } @@ -123,12 +130,27 @@ pub fn action_description(step: &Step) -> String { } /// Prefer enhanced description if present, otherwise fall back to `action_description`. +#[allow(dead_code)] pub fn effective_description(step: &Step) -> String { + effective_description_localized(step, Locale::En) +} + +/// Prefer enhanced description if present, otherwise use localized baseline text. +pub fn effective_description_localized(step: &Step, locale: Locale) -> String { let desc = step.description.as_deref().unwrap_or("").trim(); + if is_auth_placeholder(step) && (desc.is_empty() || is_auth_placeholder_description(desc)) { + return action_description_localized(step, locale); + } if !desc.is_empty() { return desc.to_string(); } - action_description(step) + action_description_localized(step, locale) +} + +fn is_auth_placeholder_description(desc: &str) -> bool { + let normalized = desc.trim(); + normalized == crate::i18n::auth_placeholder_description(Locale::En) + || normalized == crate::i18n::auth_placeholder_description(Locale::De) } /// Image data with format metadata for export. @@ -354,6 +376,18 @@ mod tests { assert!(effective_description(&s).contains("Clicked in Finder")); } + #[test] + fn effective_description_localizes_auth_placeholder_defaults() { + let mut s = sample_step(); + s.app = "Authentication".into(); + s.description = + Some("Authenticate with Touch ID or enter your password to continue.".into()); + assert_eq!( + effective_description_localized(&s, crate::i18n::Locale::De), + "Authentifiziere dich mit Touch ID oder gib dein Passwort ein, um fortzufahren." + ); + } + #[test] fn html_escape_special_chars() { assert_eq!(html_escape("a < b & c > d"), "a < b & c > d"); diff --git a/src-tauri/src/export/html.rs b/src-tauri/src/export/html.rs index 8ca298f..67b7e29 100644 --- a/src-tauri/src/export/html.rs +++ b/src-tauri/src/export/html.rs @@ -1,25 +1,43 @@ use super::helpers::{ - effective_description, html_escape, load_screenshot_optimized, marker_position_percent, - ImageTarget, + effective_description_localized, html_escape, load_screenshot_optimized, + marker_position_percent, ImageTarget, }; +use crate::i18n::Locale; use crate::recorder::types::{ActionType, Step}; /// Generate a self-contained HTML document from steps. +#[allow(dead_code)] pub fn generate(title: &str, steps: &[Step]) -> String { - generate_for(title, steps, ImageTarget::Web) + generate_localized(title, steps, Locale::En) +} + +/// Generate a self-contained localized HTML document from steps. +pub fn generate_localized(title: &str, steps: &[Step], locale: Locale) -> String { + generate_for_locale(title, steps, ImageTarget::Web, locale) } /// Generate HTML with a specific image target (Web = WebP, Pdf = JPEG). +#[allow(dead_code)] pub fn generate_for(title: &str, steps: &[Step], target: ImageTarget) -> String { + generate_for_locale(title, steps, target, Locale::En) +} + +/// Generate localized HTML with a specific image target (Web = WebP, Pdf = JPEG). +pub fn generate_for_locale( + title: &str, + steps: &[Step], + target: ImageTarget, + locale: Locale, +) -> String { let steps_html: String = steps .iter() .enumerate() - .map(|(i, step)| render_step(i + 1, step, target)) + .map(|(i, step)| render_step(i + 1, step, target, locale)) .collect(); format!( r#" - + @@ -31,29 +49,32 @@ pub fn generate_for(title: &str, steps: &[Step], target: ImageTarget) -> String

{title_esc}

-

{count} step{plural}

+

{step_count}

{steps_html}
"#, + html_lang = locale.as_html_lang(), title_esc = html_escape(title), css = CSS, - count = steps.len(), - plural = if steps.len() == 1 { "" } else { "s" }, + step_count = crate::i18n::export_step_count(locale, steps.len()), steps_html = steps_html, ) } -fn render_step(num: usize, step: &Step, target: ImageTarget) -> String { - let desc = html_escape(&effective_description(step)); +fn render_step(num: usize, step: &Step, target: ImageTarget, locale: Locale) -> String { + let desc = html_escape(&effective_description_localized(step, locale)); let image_html = step .screenshot_path .as_ref() .and_then(|p| load_screenshot_optimized(p, target, step.crop_region.as_ref())) - .map(|(b64, mime)| format!(r#"Step {num}"#)) + .map(|(b64, mime)| { + let alt = crate::i18n::export_step_image_alt(locale, num); + format!(r#"{alt}"#) + }) .unwrap_or_default(); let marker_class = match step.action { @@ -182,6 +203,14 @@ mod tests { assert!(html.contains("2 steps")); } + #[test] + fn generate_localized_german_text() { + let html = generate_localized("Anleitung", &[sample_step()], crate::i18n::Locale::De); + assert!(html.contains(r#""#)); + assert!(html.contains("1 Schritt")); + assert!(html.contains("Geklickt in Finder")); + } + #[test] fn generate_contains_dark_mode() { let html = generate("G", &[sample_step()]); diff --git a/src-tauri/src/export/markdown.rs b/src-tauri/src/export/markdown.rs index 096fa8f..f4d40c5 100644 --- a/src-tauri/src/export/markdown.rs +++ b/src-tauri/src/export/markdown.rs @@ -1,4 +1,7 @@ -use super::helpers::{effective_description, load_screenshot_optimized_image, ImageTarget}; +use super::helpers::{ + effective_description_localized, load_screenshot_optimized_image, ImageTarget, +}; +use crate::i18n::Locale; use crate::recorder::types::Step; use std::fs; use std::io::{Cursor, Write as _}; @@ -18,32 +21,44 @@ pub fn images_dir_name(output_path: &Path) -> String { /// Generate markdown content. `images_dir` is the relative folder name for images. /// `image_exts` maps step index (0-based) to file extension ("webp" or "png"). +#[allow(dead_code)] pub fn generate_content( title: &str, steps: &[Step], images_dir: &str, image_exts: &[&str], +) -> String { + generate_content_localized(title, steps, images_dir, image_exts, Locale::En) +} + +pub fn generate_content_localized( + title: &str, + steps: &[Step], + images_dir: &str, + image_exts: &[&str], + locale: Locale, ) -> String { let mut md = format!( - "# {title} — {count} step{plural}\n\n", - count = steps.len(), - plural = if steps.len() == 1 { "" } else { "s" }, + "# {title} — {step_count}\n\n", + step_count = crate::i18n::export_step_count(locale, steps.len()), ); for (i, step) in steps.iter().enumerate() { let num = i + 1; - let desc = effective_description(step); + let desc = effective_description_localized(step, locale); - md.push_str(&format!("## Step {num}\n\n")); + md.push_str(&format!( + "## {}\n\n", + crate::i18n::export_step_heading(locale, num) + )); md.push_str(&format!("**{desc}**\n\n")); // Image reference (relative path into images dir) if step.screenshot_path.is_some() { let ext = image_exts.get(i).unwrap_or(&"png"); - md.push_str(&format!( - "![Step {num}](<./{images_dir}/step-{num}.{ext}>)\n\n" - )); + let alt = crate::i18n::export_step_image_alt(locale, num); + md.push_str(&format!("![{alt}](<./{images_dir}/step-{num}.{ext}>)\n\n")); } if let Some(note) = &step.note { @@ -57,7 +72,17 @@ pub fn generate_content( /// Write a zip archive containing the markdown file and screenshot images. /// `output_path` should end in `.zip`. The inner `.md` file derives its name /// from the zip stem: "My Guide.zip" → "My Guide.md". +#[allow(dead_code)] pub fn write(title: &str, steps: &[Step], output_path: &str) -> Result<(), String> { + write_localized(title, steps, output_path, Locale::En) +} + +pub fn write_localized( + title: &str, + steps: &[Step], + output_path: &str, + locale: Locale, +) -> Result<(), String> { let path = Path::new(output_path); let stem = path .file_stem() @@ -85,7 +110,7 @@ pub fn write(title: &str, steps: &[Step], output_path: &str) -> Result<(), Strin .iter() .map(|c| c.as_ref().map(|(_, ext)| *ext).unwrap_or("png")) .collect(); - let content = generate_content(title, steps, &images_dir, &image_exts); + let content = generate_content_localized(title, steps, &images_dir, &image_exts, locale); let buf: Vec = { let cursor = Cursor::new(Vec::new()); @@ -170,6 +195,20 @@ mod tests { assert!(md.contains("2 steps")); } + #[test] + fn generate_localized_german_text() { + let md = generate_content_localized( + "Anleitung", + &[sample_step()], + "g-images", + &["png"], + crate::i18n::Locale::De, + ); + assert!(md.starts_with("# Anleitung — 1 Schritt")); + assert!(md.contains("## Schritt 1")); + assert!(md.contains("**Geklickt in Finder")); + } + #[test] fn generate_contains_action_desc() { let md = generate_content("G", &[sample_step()], "g-images", &["png"]); diff --git a/src-tauri/src/export/mod.rs b/src-tauri/src/export/mod.rs index 6ce8e5d..70862ff 100644 --- a/src-tauri/src/export/mod.rs +++ b/src-tauri/src/export/mod.rs @@ -3,6 +3,7 @@ pub mod html; pub mod markdown; pub mod pdf; +use crate::i18n::Locale; use crate::recorder::types::Step; use std::path::Path; @@ -126,6 +127,7 @@ pub fn export( format: ExportFormat, output_path: &str, app: &tauri::AppHandle, + locale: Locale, ) -> Result<(), String> { // Pre-validate before expensive work (~500KB per step estimate) let estimated_bytes = (steps.len() as u64) * 500_000 + 100_000; @@ -133,11 +135,11 @@ pub fn export( match format { ExportFormat::Html => { - let content = html::generate(title, steps); + let content = html::generate_localized(title, steps, locale); std::fs::write(output_path, content).map_err(|e| friendly_write_error(&e, output_path)) } - ExportFormat::Markdown => markdown::write(title, steps, output_path), - ExportFormat::Pdf => pdf::write(title, steps, output_path, app), + ExportFormat::Markdown => markdown::write_localized(title, steps, output_path, locale), + ExportFormat::Pdf => pdf::write(title, steps, output_path, app, locale), } } diff --git a/src-tauri/src/export/pdf.rs b/src-tauri/src/export/pdf.rs index 41b79b3..4776341 100644 --- a/src-tauri/src/export/pdf.rs +++ b/src-tauri/src/export/pdf.rs @@ -1,3 +1,4 @@ +use crate::i18n::Locale; use crate::recorder::types::Step; use std::sync::mpsc; @@ -52,8 +53,10 @@ pub fn write( steps: &[Step], output_path: &str, app: &tauri::AppHandle, + locale: Locale, ) -> Result<(), String> { - let html = super::html::generate_for(title, steps, super::helpers::ImageTarget::Pdf); + let html = + super::html::generate_for_locale(title, steps, super::helpers::ImageTarget::Pdf, locale); let path = output_path.to_string(); let (tx, rx) = mpsc::channel::>(); diff --git a/src-tauri/src/i18n.rs b/src-tauri/src/i18n.rs new file mode 100644 index 0000000..fd84dbc --- /dev/null +++ b/src-tauri/src/i18n.rs @@ -0,0 +1,254 @@ +use std::sync::OnceLock; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Locale { + En, + De, +} + +impl Locale { + pub fn as_html_lang(self) -> &'static str { + match self { + Self::En => "en", + Self::De => "de", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppLanguage { + System, + En, + De, +} + +pub fn parse_app_language(value: Option<&str>) -> AppLanguage { + match value.map(|v| v.trim().to_lowercase()) { + Some(v) if v == "en" => AppLanguage::En, + Some(v) if v == "de" => AppLanguage::De, + Some(v) if v == "system" => AppLanguage::System, + _ => AppLanguage::System, + } +} + +pub fn resolve_locale(language: AppLanguage) -> Locale { + match language { + AppLanguage::System => system_locale(), + AppLanguage::En => Locale::En, + AppLanguage::De => Locale::De, + } +} + +pub fn system_locale() -> Locale { + static DETECTED: OnceLock = OnceLock::new(); + *DETECTED.get_or_init(detect_system_locale) +} + +fn detect_system_locale() -> Locale { + #[cfg(target_os = "macos")] + if let Some(raw) = macos_apple_locale() { + if let Some(locale) = parse_env_locale(&raw) { + return locale; + } + } + + for key in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(raw) = std::env::var(key) { + if let Some(locale) = parse_env_locale(&raw) { + return locale; + } + } + } + Locale::En +} + +fn parse_env_locale(raw: &str) -> Option { + let normalized = raw.trim().to_lowercase(); + if normalized.is_empty() { + return None; + } + let short = normalized + .split(['.', '@']) + .next() + .unwrap_or(&normalized) + .trim(); + if short.starts_with("de") { + return Some(Locale::De); + } + if short.starts_with("en") { + return Some(Locale::En); + } + None +} + +#[cfg(target_os = "macos")] +fn macos_apple_locale() -> Option { + let out = std::process::Command::new("defaults") + .args(["read", "-g", "AppleLocale"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let value = String::from_utf8(out.stdout).ok()?; + let value = value.trim().to_string(); + if value.is_empty() { + None + } else { + Some(value) + } +} + +pub fn export_step_count(locale: Locale, count: usize) -> String { + let unit = match (locale, count == 1) { + (Locale::En, true) => "step", + (Locale::En, false) => "steps", + (Locale::De, true) => "Schritt", + (Locale::De, false) => "Schritte", + }; + format!("{count} {unit}") +} + +pub fn export_step_heading(locale: Locale, num: usize) -> String { + match locale { + Locale::En => format!("Step {num}"), + Locale::De => format!("Schritt {num}"), + } +} + +pub fn export_step_image_alt(locale: Locale, num: usize) -> String { + export_step_heading(locale, num) +} + +pub fn step_action_note(locale: Locale) -> &'static str { + match locale { + Locale::En => "Note", + Locale::De => "Notiz", + } +} + +pub fn step_action_clicked_in(locale: Locale) -> &'static str { + match locale { + Locale::En => "Clicked in", + Locale::De => "Geklickt in", + } +} + +pub fn step_action_double_clicked_in(locale: Locale) -> &'static str { + match locale { + Locale::En => "Double-clicked in", + Locale::De => "Doppelt geklickt in", + } +} + +pub fn step_action_right_clicked_in(locale: Locale) -> &'static str { + match locale { + Locale::En => "Right-clicked in", + Locale::De => "Rechts geklickt in", + } +} + +pub fn step_action_shortcut_in(locale: Locale) -> &'static str { + match locale { + Locale::En => "Used keyboard shortcut in", + Locale::De => "Tastenkürzel verwendet in", + } +} + +pub fn auth_placeholder_description(locale: Locale) -> &'static str { + match locale { + Locale::En => "Authenticate with Touch ID or enter your password to continue.", + Locale::De => { + "Authentifiziere dich mit Touch ID oder gib dein Passwort ein, um fortzufahren." + } + } +} + +pub fn tray_tooltip(_locale: Locale) -> &'static str { + "StepCast" +} + +pub fn tray_recording_tooltip(locale: Locale) -> &'static str { + match locale { + Locale::En => "StepCast - Recording...", + Locale::De => "StepCast - Aufnahme läuft...", + } +} + +pub fn tray_menu_open(locale: Locale) -> &'static str { + match locale { + Locale::En => "Open StepCast", + Locale::De => "StepCast öffnen", + } +} + +pub fn tray_menu_quick_start(locale: Locale) -> &'static str { + match locale { + Locale::En => "Quick Start", + Locale::De => "Schnellstart", + } +} + +pub fn tray_menu_quit(locale: Locale) -> &'static str { + match locale { + Locale::En => "Quit StepCast", + Locale::De => "StepCast beenden", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_app_language_handles_known_values() { + assert_eq!(parse_app_language(Some("system")), AppLanguage::System); + assert_eq!(parse_app_language(Some("en")), AppLanguage::En); + assert_eq!(parse_app_language(Some("de")), AppLanguage::De); + assert_eq!(parse_app_language(Some(" DE ")), AppLanguage::De); + } + + #[test] + fn parse_app_language_falls_back_to_system() { + assert_eq!(parse_app_language(None), AppLanguage::System); + assert_eq!(parse_app_language(Some("fr")), AppLanguage::System); + assert_eq!(parse_app_language(Some("")), AppLanguage::System); + } + + #[test] + fn parse_env_locale_parses_de_and_en() { + assert_eq!(parse_env_locale("de_DE.UTF-8"), Some(Locale::De)); + assert_eq!(parse_env_locale("en_US.UTF-8"), Some(Locale::En)); + assert_eq!(parse_env_locale("de@euro"), Some(Locale::De)); + assert_eq!(parse_env_locale("fr_FR.UTF-8"), None); + } + + #[test] + fn resolve_locale_resolves_explicit_languages() { + assert_eq!(resolve_locale(AppLanguage::En), Locale::En); + assert_eq!(resolve_locale(AppLanguage::De), Locale::De); + } + + #[test] + fn export_text_helpers_render_translated_strings() { + assert_eq!(export_step_count(Locale::En, 2), "2 steps"); + assert_eq!(export_step_count(Locale::De, 1), "1 Schritt"); + assert_eq!(export_step_heading(Locale::De, 3), "Schritt 3"); + assert_eq!( + auth_placeholder_description(Locale::De), + "Authentifiziere dich mit Touch ID oder gib dein Passwort ein, um fortzufahren." + ); + } + + #[test] + fn tray_helpers_render_translated_strings() { + assert_eq!(tray_menu_open(Locale::En), "Open StepCast"); + assert_eq!(tray_menu_open(Locale::De), "StepCast öffnen"); + assert_eq!(tray_menu_quick_start(Locale::De), "Schnellstart"); + assert_eq!(tray_menu_quit(Locale::De), "StepCast beenden"); + assert_eq!( + tray_recording_tooltip(Locale::De), + "StepCast - Aufnahme läuft..." + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f2b5189..3b584aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ mod apple_intelligence; mod export; +mod i18n; mod panel; mod recorder; mod startup_state; @@ -169,20 +170,21 @@ fn get_apple_intelligence_eligibility() -> AppleIntelligenceEligibility { }; } - let availability = match crate::apple_intelligence::availability() { - Ok(a) => a, - Err(err) => { - return AppleIntelligenceEligibility { - eligible: false, - reason: "Could not check Apple Intelligence availability.".to_string(), - details: Some(format!( - "{}; {}", - platform_details.unwrap_or_else(|| format!("macos (unknown) ({arch})")), - err - )), - }; - } - }; + let availability = + match crate::apple_intelligence::availability(Some(crate::i18n::system_locale())) { + Ok(a) => a, + Err(err) => { + return AppleIntelligenceEligibility { + eligible: false, + reason: "Could not check Apple Intelligence availability.".to_string(), + details: Some(format!( + "{}; {}", + platform_details.unwrap_or_else(|| format!("macos (unknown) ({arch})")), + err + )), + }; + } + }; if availability.available { return AppleIntelligenceEligibility { @@ -1035,6 +1037,7 @@ fn generate_step_descriptions( state: tauri::State<'_, RecorderAppState>, mode: Option, step_ids: Option>, + app_language: Option, ) -> Result<(), String> { // Serialize description generation to avoid racing step updates. if state.ai_descriptions_running.swap(true, Ordering::SeqCst) { @@ -1064,6 +1067,7 @@ fn generate_step_descriptions( // Slightly longer than a one-liner, still "no novels" — enables useful context like "from the Dock". let max_chars = 110usize; + let locale = i18n::resolve_locale(i18n::parse_app_language(app_language.as_deref())); let mut ids_to_generate: Vec = Vec::new(); let (steps_to_generate, session_dir): (Vec, std::path::PathBuf) = { let mut session_lock = state.session.lock().map_err(|_| "session lock poisoned")?; @@ -1153,7 +1157,7 @@ fn generate_step_descriptions( let generate_steps = steps_to_generate; let resp = tauri::async_runtime::spawn_blocking(move || { - crate::apple_intelligence::generate_descriptions(generate_steps, max_chars) + crate::apple_intelligence::generate_descriptions(generate_steps, max_chars, locale) }) .await; @@ -1380,8 +1384,10 @@ async fn export_guide( title: String, format: String, output_path: String, + app_language: Option, ) -> Result<(), String> { let fmt = export::ExportFormat::from_str(&format)?; + let locale = i18n::resolve_locale(i18n::parse_app_language(app_language.as_deref())); let steps = { let session_lock = state.session.lock().map_err(|_| "session lock poisoned")?; session_lock @@ -1389,7 +1395,7 @@ async fn export_guide( .map(|s| s.get_steps().to_vec()) .unwrap_or_default() }; - export::export(&title, &steps, fmt, &output_path, &app) + export::export(&title, &steps, fmt, &output_path, &app, locale) } #[tauri::command] diff --git a/src-tauri/src/recorder/pipeline/mod.rs b/src-tauri/src/recorder/pipeline/mod.rs index f9f9e97..0e328ca 100644 --- a/src-tauri/src/recorder/pipeline/mod.rs +++ b/src-tauri/src/recorder/pipeline/mod.rs @@ -1657,7 +1657,9 @@ pub fn process_click( // user actually saw at click-time. debug_log( session, - &format!("transient_region_capture: role={ax_role:?} overlay_kind={overlay_kind:?}"), + &format!( + "transient_region_capture: role={ax_role:?} overlay_kind={overlay_kind:?}" + ), ); let bounds = if overlay_kind == helpers::TitlelessOverlayKind::NotOverlay { // Keep OCR/context focused around click for weak AX roles (e.g. AXGroup), diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 7c0c30d..c23671b 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -170,7 +170,8 @@ pub fn set_recording_icon(app_handle: &AppHandle) -> tauri::Result<()> { tray.set_icon(Some(icon))?; tray.set_icon_as_template(false)?; // Keep red color, don't adapt to system theme - tray.set_tooltip(Some("StepCast - Recording..."))?; + let locale = crate::i18n::system_locale(); + tray.set_tooltip(Some(crate::i18n::tray_recording_tooltip(locale)))?; Ok(()) } @@ -191,7 +192,8 @@ pub fn set_default_icon(app_handle: &AppHandle) -> tauri::Result<()> { tray.set_icon(Some(icon))?; tray.set_icon_as_template(true)?; // Adapt to system theme - tray.set_tooltip(Some("StepCast"))?; + let locale = crate::i18n::system_locale(); + tray.set_tooltip(Some(crate::i18n::tray_tooltip(locale)))?; Ok(()) } @@ -231,12 +233,30 @@ fn resolve_tray_icon_path(app_handle: &AppHandle) -> tauri::Result { pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { let tray_icon_path = resolve_tray_icon_path(app_handle)?; let icon = Image::from_path(tray_icon_path)?; - - let open = MenuItem::with_id(app_handle, "open", "Open StepCast", true, None::<&str>)?; - let quick_start = - MenuItem::with_id(app_handle, "quick_start", "Quick Start", true, None::<&str>)?; + let locale = crate::i18n::system_locale(); + + let open = MenuItem::with_id( + app_handle, + "open", + crate::i18n::tray_menu_open(locale), + true, + None::<&str>, + )?; + let quick_start = MenuItem::with_id( + app_handle, + "quick_start", + crate::i18n::tray_menu_quick_start(locale), + true, + None::<&str>, + )?; let sep = PredefinedMenuItem::separator(app_handle)?; - let quit = MenuItem::with_id(app_handle, "quit", "Quit StepCast", true, None::<&str>)?; + let quit = MenuItem::with_id( + app_handle, + "quit", + crate::i18n::tray_menu_quit(locale), + true, + None::<&str>, + )?; let menu = Menu::with_items(app_handle, &[&open, &quick_start, &sep, &quit])?; TrayIconBuilder::with_id(TRAY_ID) @@ -256,7 +276,7 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { _ => {} } }) - .tooltip("StepCast") + .tooltip(crate::i18n::tray_tooltip(locale)) .on_tray_icon_event(|tray, event| { let app_handle = tray.app_handle(); diff --git a/src-tauri/swift/stepcast_ai_helper.swift b/src-tauri/swift/stepcast_ai_helper.swift index 71c3f39..4a271da 100644 --- a/src-tauri/swift/stepcast_ai_helper.swift +++ b/src-tauri/swift/stepcast_ai_helper.swift @@ -52,6 +52,7 @@ struct StepInput: Codable { struct GenerateRequest: Codable { let steps: [StepInput] let maxChars: Int? + let appLanguage: String? } struct GenerateResultItem: Codable { @@ -80,6 +81,32 @@ struct GenerateResponse: Codable { let failures: [GenerateFailureItem] } +enum HelperLocale: String { + case en + case de + + static func fromAppLanguage(_ raw: String?) -> HelperLocale { + let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized.hasPrefix("de") { return .de } + return .en + } +} + +var activeLocale: HelperLocale = .en + +@inline(__always) +func isGermanLocale() -> Bool { activeLocale == .de } + +@inline(__always) +func l(_ english: String, _ german: String) -> String { + isGermanLocale() ? german : english +} + +func languageArg(from args: [String]) -> String? { + guard let idx = args.firstIndex(of: "--lang"), idx + 1 < args.count else { return nil } + return args[idx + 1] +} + func encodeJSON(_ value: T) -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] @@ -100,7 +127,10 @@ func unsupportedModelAvailability() -> AvailabilityResponse { AvailabilityResponse( available: false, reason: "unsupported", - details: "Apple Intelligence framework is not available on this system." + details: l( + "Apple Intelligence framework is not available on this system.", + "Apple-Intelligence-Framework ist auf diesem System nicht verfügbar." + ) ) } @@ -121,13 +151,22 @@ func availabilityReasonCode(_ reason: SystemLanguageModel.Availability.Unavailab func availabilityReasonDetails(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String { switch reason { case .deviceNotEligible: - return "This device is not eligible for Apple Intelligence." + return l( + "This device is not eligible for Apple Intelligence.", + "Dieses Gerät ist nicht für Apple Intelligence geeignet." + ) case .appleIntelligenceNotEnabled: - return "Apple Intelligence is disabled. Enable it in System Settings." + return l( + "Apple Intelligence is disabled. Enable it in System Settings.", + "Apple Intelligence ist deaktiviert. Aktiviere es in den Systemeinstellungen." + ) case .modelNotReady: - return "The model is not ready yet. Try again in a moment." + return l( + "The model is not ready yet. Try again in a moment.", + "Das Modell ist noch nicht bereit. Bitte versuche es gleich erneut." + ) @unknown default: - return "Apple Intelligence is unavailable." + return l("Apple Intelligence is unavailable.", "Apple Intelligence ist nicht verfügbar.") } } @@ -150,18 +189,26 @@ func checkAvailability() -> AvailabilityResponse { #endif func generateDescriptions(_ req: GenerateRequest) async -> GenerateResponse { + activeLocale = HelperLocale.fromAppLanguage(req.appLanguage) let maxChars = max(20, min(req.maxChars ?? 110, 140)) let availability = checkAvailability() if !availability.available { let failures = req.steps.map { - GenerateFailureItem(id: $0.id, error: availability.details ?? "Apple Intelligence unavailable.") + GenerateFailureItem( + id: $0.id, + error: availability.details ?? l( + "Apple Intelligence unavailable.", + "Apple Intelligence ist nicht verfügbar." + ) + ) } return GenerateResponse(results: [], failures: failures) } - let instructions = - "You generate concise UI tutorial step descriptions. " + - "Keep output short and specific. Never invent UI labels; use only provided context." + let instructions = l( + "You generate concise UI tutorial step descriptions. Keep output short and specific. Never invent UI labels; use only provided context.", + "Du erzeugst prägnante Schrittbeschreibungen für UI-Tutorials. Halte die Ausgabe kurz und konkret. Erfinde keine UI-Labels; nutze nur bereitgestellten Kontext." + ) var results: [GenerateResultItem] = [] let failures: [GenerateFailureItem] = [] @@ -282,13 +329,17 @@ struct StepCastAIHelper { switch cmd { case "availability": + activeLocale = HelperLocale.fromAppLanguage(languageArg(from: args)) writeStdout(encodeJSON(checkAvailability())) case "generate": let input = readStdin() let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase guard let req = try? decoder.decode(GenerateRequest.self, from: input) else { - let resp = GenerateResponse(results: [], failures: [GenerateFailureItem(id: "*", error: "Invalid JSON input.")]) + let resp = GenerateResponse( + results: [], + failures: [GenerateFailureItem(id: "*", error: l("Invalid JSON input.", "Ungültige JSON-Eingabe."))] + ) writeStdout(encodeJSON(resp)) exit(2) } diff --git a/src-tauri/swift/stepcast_ai_helper_descriptions.swift b/src-tauri/swift/stepcast_ai_helper_descriptions.swift index f03325a..1c476b6 100644 --- a/src-tauri/swift/stepcast_ai_helper_descriptions.swift +++ b/src-tauri/swift/stepcast_ai_helper_descriptions.swift @@ -15,6 +15,56 @@ func actionVerb(_ action: String) -> String { } } +func localizedVerb(_ canonicalVerb: String) -> String { + switch canonicalVerb { + case "Double-click": + return l("Double-click", "Doppelklicke") + case "Right-click": + return l("Right-click", "Klicke mit der rechten Maustaste") + case "Press": + return l("Press", "Drücke") + case "Add a note": + return l("Add a note", "Füge eine Notiz hinzu") + case "Choose": + return l("Choose", "Wähle") + case "Select": + return l("Select", "Wähle") + case "Open": + return l("Open", "Öffne") + case "Close": + return l("Close", "Schließe") + case "Enable": + return l("Enable", "Aktiviere") + case "Disable": + return l("Disable", "Deaktiviere") + default: + return l("Click", "Klicke") + } +} + +func localizedKindNoun(_ kind: String) -> String { + switch kind { + case "item": + return l("item", "Element") + case "button": + return l("button", "Button") + case "tab": + return l("tab", "Tab") + case "text field": + return l("text field", "Textfeld") + case "list item": + return l("item", "Element") + case "menu item": + return l("menu item", "Menüeintrag") + case "menu bar item": + return l("menu bar icon", "Menüleisten-Symbol") + case "checkbox": + return l("checkbox", "Kontrollkästchen") + default: + return kind + } +} + func normalizeForMatch(_ s: String) -> String { s .trimmingCharacters(in: .whitespacesAndNewlines) @@ -625,18 +675,18 @@ func locationHint(_ step: StepInput, kind: String) -> String? { let selfIdent = (ax.identifier ?? "").lowercased() if sub.contains("sourcelist") || sub.contains("sidebar") || ident.contains("sidebar") || selfIdent.contains("sidebar") { - return "sidebar" + return l("sidebar", "Seitenleiste") } } if kind == "menu bar item" { - return "menu bar" + return l("menu bar", "Menüleiste") } let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if app == "finder" { // If we can’t prove “sidebar”, still add a non-guessy hint for list interactions. - if kind == "list item" { return "file list" } + if kind == "list item" { return l("file list", "Dateiliste") } } return nil } @@ -658,19 +708,19 @@ func appContextSuffix(_ step: StepInput, location: String?) -> String { } if let location = location, !location.isEmpty { if lower == "finder" { - if let win = win { return " in the Finder \(location) (\(win))" } - return " in the Finder \(location)" + if let win = win { return l(" in the Finder \(location) (\(win))", " in der Finder-\(location) (\(win))") } + return l(" in the Finder \(location)", " in der Finder-\(location)") } - if let win = win { return " in \(app) \(location) (\(win))" } - return " in \(app) \(location)" + if let win = win { return l(" in \(app) \(location) (\(win))", " in \(app) \(location) (\(win))") } + return l(" in \(app) \(location)", " in \(app) \(location)") } if let win = win { if lower == "finder" { - return " in Finder (\(win))" + return l(" in Finder (\(win))", " in Finder (\(win))") } - return " in \(app) (\(win))" + return l(" in \(app) (\(win))", " in \(app) (\(win))") } - return " in \(app)" + return l(" in \(app)", " in \(app)") } func classifyKind(_ step: StepInput) -> String { @@ -983,81 +1033,112 @@ func baselineDescription(_ step: StepInput, kind: String, label: String, locatio let q = safeQuoted(cleanLabel) let suffix = appContextSuffix(step, location: location) let verb = actionVerb(step.action) + let rightClickVerb = localizedVerb("Right-click") + let doubleClickVerb = localizedVerb("Double-click") + let pressVerb = localizedVerb("Press") var s: String if kind == "close button" { let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines) if !app.isEmpty && app.lowercased() != "application" && app.lowercased() != "dock" { - s = "Close the \(app) window." + s = l("Close the \(app) window.", "Schließe das \(app)-Fenster.") } else { - s = "Close the window." + s = l("Close the window.", "Schließe das Fenster.") } } else if kind == "minimize button" { let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines) if !app.isEmpty && app.lowercased() != "application" && app.lowercased() != "dock" { - s = "Minimize the \(app) window." + s = l("Minimize the \(app) window.", "Minimiere das \(app)-Fenster.") } else { - s = "Minimize the window." + s = l("Minimize the window.", "Minimiere das Fenster.") } } else if kind == "zoom button" { let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines) if !app.isEmpty && app.lowercased() != "application" && app.lowercased() != "dock" { - s = "Zoom the \(app) window." + s = l("Zoom the \(app) window.", "Vergrößere das \(app)-Fenster.") } else { - s = "Zoom the window." + s = l("Zoom the window.", "Vergrößere das Fenster.") } } else if kind == "menu bar item" { let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines) if !app.isEmpty && app.lowercased() != "application" && app.lowercased() != "dock" { - s = "Click the \(app) icon in the menu bar to open its menu." + s = l( + "Click the \(app) icon in the menu bar to open its menu.", + "Klicke auf das \(app)-Symbol in der Menüleiste, um das Menü zu öffnen." + ) } else if !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - s = "Click \(q) in the menu bar to open its menu." + s = l( + "Click \(q) in the menu bar to open its menu.", + "Klicke in der Menüleiste auf \(q), um das Menü zu öffnen." + ) } else { - s = "Click the menu bar icon to open its menu." + s = l( + "Click the menu bar icon to open its menu.", + "Klicke auf das Menüleisten-Symbol, um das Menü zu öffnen." + ) } } else if kind == "text field" { // We only record clicks (no global key capture); avoid pretending we saw typing. if label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - s = "Click the text field\(suffix)." + s = l("Click the text field\(suffix).", "Klicke auf das Textfeld\(suffix).") } else if label.allSatisfy({ $0.isLetter || $0.isNumber }) && !label.contains(" ") { - s = "Click the \(label) field\(suffix)." + s = l("Click the \(label) field\(suffix).", "Klicke auf das Feld \(label)\(suffix).") } else { - s = "Click \(q) field\(suffix)." + s = l("Click \(q) field\(suffix).", "Klicke auf das Feld \(q)\(suffix).") } } else if isDockStep(step) { - s = label.isEmpty ? "Open the app from the Dock." : "Open \(label) from the Dock." + s = label.isEmpty + ? l("Open the app from the Dock.", "Öffne die App im Dock.") + : l("Open \(label) from the Dock.", "Öffne \(label) im Dock.") } else if kind == "menu item" { - s = q.isEmpty ? "Choose the menu item\(suffix)." : "Choose \(q) from the menu\(suffix)." + s = q.isEmpty + ? l("Choose the menu item\(suffix).", "Wähle den Menüeintrag\(suffix).") + : l("Choose \(q) from the menu\(suffix).", "Wähle \(q) aus dem Menü\(suffix).") } else if kind == "checkbox" { - let checkboxAction = checkboxVerb(step) - s = q.isEmpty ? "\(checkboxAction) the checkbox\(suffix)." : "\(checkboxAction) \(q)\(suffix)." + let checkboxAction = localizedVerb(checkboxVerb(step)) + s = q.isEmpty + ? l("\(checkboxAction) the checkbox\(suffix).", "\(checkboxAction) das Kontrollkästchen\(suffix).") + : "\(checkboxAction) \(q)\(suffix)." } else if verb == "Right-click" { - s = q.isEmpty ? "Right-click the \(kind)\(suffix)." : "Right-click \(q)\(suffix)." + s = q.isEmpty + ? "\(rightClickVerb) auf \(localizedKindNoun(kind))\(suffix)." + : "\(rightClickVerb) auf \(q)\(suffix)." } else if verb == "Double-click" { - s = q.isEmpty ? "Double-click the \(kind)\(suffix)." : "Double-click \(q)\(suffix)." + s = q.isEmpty + ? "\(doubleClickVerb) auf \(localizedKindNoun(kind))\(suffix)." + : "\(doubleClickVerb) auf \(q)\(suffix)." } else if verb == "Press" { let note = step.note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !note.isEmpty { - s = "Press \(note)\(suffix)." + s = "\(pressVerb) \(note)\(suffix)." } else { - s = "Press the keyboard shortcut\(suffix)." + s = l("Press the keyboard shortcut\(suffix).", "Drücke das Tastenkürzel\(suffix).") } } else { switch kind { case "list item": - s = q.isEmpty ? "Select the item\(suffix)." : "Select \(q)\(suffix)." + s = q.isEmpty + ? l("Select the item\(suffix).", "Wähle das Element\(suffix).") + : l("Select \(q)\(suffix).", "Wähle \(q)\(suffix).") case "tab": - s = q.isEmpty ? "Click the tab\(suffix)." : "Click the \(q) tab\(suffix)." + s = q.isEmpty + ? l("Click the tab\(suffix).", "Klicke auf den Tab\(suffix).") + : l("Click the \(q) tab\(suffix).", "Klicke auf den Tab \(q)\(suffix).") case "button": if q.isEmpty { - s = "Click the button\(suffix)." + s = l("Click the button\(suffix).", "Klicke auf den Button\(suffix).") } else if cleanLabel.allSatisfy({ $0.isLetter || $0.isNumber || $0 == " " }) && !cleanLabel.contains(" ") { - s = "Click the \(cleanLabel) button\(suffix)." + s = l( + "Click the \(cleanLabel) button\(suffix).", + "Klicke auf den Button \(cleanLabel)\(suffix)." + ) } else { - s = "Click \(q)\(suffix)." + s = l("Click \(q)\(suffix).", "Klicke auf \(q)\(suffix).") } default: - s = q.isEmpty ? "Click the item\(suffix)." : "Click \(q)\(suffix)." + s = q.isEmpty + ? l("Click the item\(suffix).", "Klicke auf das Element\(suffix).") + : l("Click \(q)\(suffix).", "Klicke auf \(q)\(suffix).") } } @@ -1078,6 +1159,19 @@ func startsWithVerb(_ s: String) -> Bool { || t.hasPrefix("press") || t.hasPrefix("type") || t.hasPrefix("drag") + || t.hasPrefix("klicke") + || t.hasPrefix("doppelklicke") + || t.hasPrefix("wähle") + || t.hasPrefix("waehle") + || t.hasPrefix("öffne") + || t.hasPrefix("oeffne") + || t.hasPrefix("schließe") + || t.hasPrefix("schliesse") + || t.hasPrefix("aktiviere") + || t.hasPrefix("deaktiviere") + || t.hasPrefix("drücke") + || t.hasPrefix("druecke") + || t.hasPrefix("ziehe") } struct QualityGateDecision { @@ -1114,7 +1208,7 @@ func applyQualityGate(step: StepInput, kind: String, baseline: String, candidate return QualityGateDecision(text: baseline, reason: "prefer_baseline_for_kind") } - let pref = preferredVerb(step, kind: kind).lowercased() + let pref = localizedVerb(preferredVerb(step, kind: kind)).lowercased() if !cand.lowercased().hasPrefix(pref) { // Keep the style consistent and avoid generic verbs ("Click") when we have a stronger signal. return QualityGateDecision(text: baseline, reason: "candidate_wrong_verb") @@ -1123,13 +1217,22 @@ func applyQualityGate(step: StepInput, kind: String, baseline: String, candidate if isDockStep(step) && !candNorm.contains("dock") { return QualityGateDecision(text: baseline, reason: "dock_context_missing") } - if kind == "menu item" && !candNorm.contains("menu") { + if kind == "menu item" + && !candNorm.contains("menu") + && !candNorm.contains("menü") + { return QualityGateDecision(text: baseline, reason: "menu_context_missing") } - if kind == "menu bar item" && !candNorm.contains("menu bar") { + if kind == "menu bar item" + && !candNorm.contains("menu bar") + && !candNorm.contains("menüleiste") + { return QualityGateDecision(text: baseline, reason: "menu_bar_context_missing") } - if kind != "tab" && candNorm.contains(" tab") { + if kind != "tab" + && (candNorm.contains(" tab") + || candNorm.contains(" registerkarte")) + { // Avoid "tab" hallucinations when we don't have a tab signal. return QualityGateDecision(text: baseline, reason: "tab_hallucination") } @@ -1203,54 +1306,84 @@ func promptForStep( maxChars: Int ) -> String { var lines: [String] = [] - lines.append("Write ONE short UI tutorial step description.") - lines.append("Rules:") - lines.append("- ONE sentence, max \(maxChars) characters.") - lines.append("- Start with a verb (e.g. Click, Double-click, Right-click, Close, Open, Choose, Select).") - lines.append("- No numbering, no markdown, no quotes unless quoting a UI label.") - lines.append("- Do NOT invent UI labels. Use the provided label; if missing, stay generic.") - lines.append("- Avoid vague output like \"Click Finder.\"; include location when known (Dock/menu/dialog).") - lines.append("- If unsure, return the Baseline exactly.") - lines.append("- Return ONLY the description text.") + lines.append(l( + "Write ONE short UI tutorial step description.", + "Schreibe EINE kurze UI-Tutorial-Schrittbeschreibung." + )) + lines.append(l("Rules:", "Regeln:")) + lines.append(l( + "- ONE sentence, max \(maxChars) characters.", + "- EINE Satzzeile, maximal \(maxChars) Zeichen." + )) + lines.append(l( + "- Start with a verb (e.g. Click, Double-click, Right-click, Close, Open, Choose, Select).", + "- Starte mit einem Verb (z. B. Klicke, Doppelklicke, Wähle, Öffne, Schließe)." + )) + lines.append(l( + "- No numbering, no markdown, no quotes unless quoting a UI label.", + "- Keine Nummerierung, kein Markdown, keine Anführungszeichen außer bei UI-Labels." + )) + lines.append(l( + "- Do NOT invent UI labels. Use the provided label; if missing, stay generic.", + "- Erfinde KEINE UI-Labels. Nutze nur bereitgestellte Labels; sonst bleibe generisch." + )) + lines.append(l( + "- Avoid vague output like \"Click Finder.\"; include location when known (Dock/menu/dialog).", + "- Vermeide vage Ausgaben wie \"Klicke Finder.\"; nutze Ortshinweise (Dock/Menü/Dialog), wenn bekannt." + )) + lines.append(l( + "- If unsure, return the Baseline exactly.", + "- Wenn unsicher, gib die Baseline exakt zurück." + )) + lines.append(l( + "- Return ONLY the description text.", + "- Gib NUR den Beschreibungstext zurück." + )) lines.append("") - lines.append("Detected UI element kind: \(kind)") - lines.append("Click position: x=\(Int(step.clickXPercent))% y=\(Int(step.clickYPercent))% (from top-left)") - lines.append("Preferred verb: \(preferredVerb(step, kind: kind))") - if let location = location { lines.append("Location hint: \(location)") } + lines.append(l("Detected UI element kind: \(kind)", "Erkannter UI-Elementtyp: \(kind)")) + lines.append(l( + "Click position: x=\(Int(step.clickXPercent))% y=\(Int(step.clickYPercent))% (from top-left)", + "Klickposition: x=\(Int(step.clickXPercent))% y=\(Int(step.clickYPercent))% (von oben links)" + )) + lines.append(l( + "Preferred verb: \(localizedVerb(preferredVerb(step, kind: kind)))", + "Bevorzugtes Verb: \(localizedVerb(preferredVerb(step, kind: kind)))" + )) + if let location = location { lines.append(l("Location hint: \(location)", "Ortshinweis: \(location)")) } if !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - lines.append("Grounding label: \(label)") + lines.append(l("Grounding label: \(label)", "Grounding-Label: \(label)")) } if let ocr = ocr, !ocr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - lines.append("OCR near click: \(ocr)") + lines.append(l("OCR near click: \(ocr)", "OCR nahe Klick: \(ocr)")) } - lines.append("Baseline (safe): \(baseline)") + lines.append(l("Baseline (safe): \(baseline)", "Baseline (sicher): \(baseline)")) lines.append("") - lines.append("Action: \(actionVerb(step.action))") - lines.append("App: \(step.app)") + lines.append(l("Action: \(localizedVerb(actionVerb(step.action)))", "Aktion: \(localizedVerb(actionVerb(step.action)))")) + lines.append(l("App: \(step.app)", "App: \(step.app)")) if !step.windowTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - lines.append("Window title: \(step.windowTitle)") + lines.append(l("Window title: \(step.windowTitle)", "Fenstertitel: \(step.windowTitle)")) } if let ax = step.ax { - lines.append("AX role: \(ax.role)") - if let sub = ax.subrole { lines.append("AX subrole: \(sub)") } - if let desc = ax.roleDescription { lines.append("AX role description: \(desc)") } - if let ident = ax.identifier { lines.append("AX identifier: \(ident)") } - lines.append("AX label: \(ax.label)") - if let containerRole = ax.containerRole { lines.append("AX container role: \(containerRole)") } - if let containerSub = ax.containerSubrole { lines.append("AX container subrole: \(containerSub)") } - if let containerIdent = ax.containerIdentifier { lines.append("AX container identifier: \(containerIdent)") } - if let windowRole = ax.windowRole { lines.append("AX window role: \(windowRole)") } - if let windowSubrole = ax.windowSubrole { lines.append("AX window subrole: \(windowSubrole)") } - if let topRole = ax.topLevelRole { lines.append("AX top role: \(topRole)") } - if let topSubrole = ax.topLevelSubrole { lines.append("AX top subrole: \(topSubrole)") } - if let dialogRole = ax.parentDialogRole { lines.append("AX dialog role: \(dialogRole)") } - if let dialogSub = ax.parentDialogSubrole { lines.append("AX dialog subrole: \(dialogSub)") } - if let checked = ax.isChecked { lines.append("AX checked: \(checked ? "true" : "false")") } - if ax.isDefaultButton { lines.append("AX hint: default button") } - if ax.isCancelButton { lines.append("AX hint: cancel button") } + lines.append(l("AX role: \(ax.role)", "AX-Rolle: \(ax.role)")) + if let sub = ax.subrole { lines.append(l("AX subrole: \(sub)", "AX-Unterrolle: \(sub)")) } + if let desc = ax.roleDescription { lines.append(l("AX role description: \(desc)", "AX-Rollenbeschreibung: \(desc)")) } + if let ident = ax.identifier { lines.append(l("AX identifier: \(ident)", "AX-Identifier: \(ident)")) } + lines.append(l("AX label: \(ax.label)", "AX-Label: \(ax.label)")) + if let containerRole = ax.containerRole { lines.append(l("AX container role: \(containerRole)", "AX-Containerrolle: \(containerRole)")) } + if let containerSub = ax.containerSubrole { lines.append(l("AX container subrole: \(containerSub)", "AX-Containerunterrolle: \(containerSub)")) } + if let containerIdent = ax.containerIdentifier { lines.append(l("AX container identifier: \(containerIdent)", "AX-Container-Identifier: \(containerIdent)")) } + if let windowRole = ax.windowRole { lines.append(l("AX window role: \(windowRole)", "AX-Fensterrolle: \(windowRole)")) } + if let windowSubrole = ax.windowSubrole { lines.append(l("AX window subrole: \(windowSubrole)", "AX-Fensterunterrolle: \(windowSubrole)")) } + if let topRole = ax.topLevelRole { lines.append(l("AX top role: \(topRole)", "AX-Toprolle: \(topRole)")) } + if let topSubrole = ax.topLevelSubrole { lines.append(l("AX top subrole: \(topSubrole)", "AX-Topunterrolle: \(topSubrole)")) } + if let dialogRole = ax.parentDialogRole { lines.append(l("AX dialog role: \(dialogRole)", "AX-Dialogrolle: \(dialogRole)")) } + if let dialogSub = ax.parentDialogSubrole { lines.append(l("AX dialog subrole: \(dialogSub)", "AX-Dialogunterrolle: \(dialogSub)")) } + if let checked = ax.isChecked { lines.append(l("AX checked: \(checked ? "true" : "false")", "AX aktiviert: \(checked ? "true" : "false")")) } + if ax.isDefaultButton { lines.append(l("AX hint: default button", "AX-Hinweis: Standardbutton")) } + if ax.isCancelButton { lines.append(l("AX hint: cancel button", "AX-Hinweis: Abbrechen-Button")) } } if let note = step.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { - lines.append("User note: \(note)") + lines.append(l("User note: \(note)", "Benutzernotiz: \(note)")) } return lines.joined(separator: "\n") } diff --git a/src/App.css b/src/App.css index ba9c609..95a3458 100644 --- a/src/App.css +++ b/src/App.css @@ -680,12 +680,31 @@ button:focus, button:focus-visible { /* Action Bar (Stopped State) */ .action-bar { - display: flex; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; + align-items: stretch; +} + +.action-bar > * { + min-width: 0; } .action-bar .button { - flex: 1; + width: 100%; + min-width: 0; + padding-inline: 10px; +} + +.action-bar .action-dropdown { + min-width: 0; +} + +.action-bar .button .action-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* Dropdown menu (New button) */ @@ -930,6 +949,22 @@ button:focus, button:focus-visible { letter-spacing: 0.04em; } +.settings-select { + width: 100%; + min-height: 32px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + padding: 6px 10px; + outline: none; +} + +.settings-select:focus { + border-color: var(--accent); +} + .ai-settings-label { display: inline-flex; align-items: center; diff --git a/src/components/CropEditorModal.tsx b/src/components/CropEditorModal.tsx index 64959d2..2c1d887 100644 --- a/src/components/CropEditorModal.tsx +++ b/src/components/CropEditorModal.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import ReactCrop, { type PercentCrop } from "react-image-crop"; import type { BoundsPercent } from "../types/step"; import { isFullCrop, normalizeCropRegion } from "../utils/stepCrop"; +import { useI18n } from "../i18n"; const FULL_PERCENT_CROP: PercentCrop = { unit: "%", @@ -61,6 +62,7 @@ export default function CropEditorModal({ onImageError, onImageLoad, }: CropEditorModalProps) { + const { t } = useI18n(); const [cropDraft, setCropDraft] = useState(() => toPercentCrop(initialCropRegion), ); @@ -78,8 +80,8 @@ export default function CropEditorModal({
e.stopPropagation()}>
-
Adjust Focus Crop
-
@@ -102,7 +104,7 @@ export default function CropEditorModal({
{`Adjust e.preventDefault()} @@ -133,9 +135,9 @@ export default function CropEditorModal({
- - - + + +
diff --git a/src/components/EditorStepCard.tsx b/src/components/EditorStepCard.tsx index 1890158..56ce892 100644 --- a/src/components/EditorStepCard.tsx +++ b/src/components/EditorStepCard.tsx @@ -5,6 +5,7 @@ import { convertFileSrc } from "@tauri-apps/api/core"; import CropEditorModal from "./CropEditorModal"; import StepScreenshot from "./StepScreenshot"; import type { BoundsPercent, Step } from "../types/step"; +import { useI18n } from "../i18n"; type EditorStepCardProps = { step: Step; @@ -39,6 +40,7 @@ export default memo(function EditorStepCard({ isSelectionActive = false, onToggleSelect, }: EditorStepCardProps) { + const { t } = useI18n(); const { attributes, listeners, @@ -87,17 +89,17 @@ export default memo(function EditorStepCard({ const actionDesc = step.action === "DoubleClick" - ? "Double-clicked in" + ? t("step.action.double_clicked_in") : step.action === "RightClick" - ? "Right-clicked in" + ? t("step.action.right_clicked_in") : step.action === "Shortcut" - ? "Used keyboard shortcut in" - : "Clicked in"; + ? t("step.action.shortcut_in") + : t("step.action.clicked_in"); const authDescription = step.description && step.description.trim().length > 0 ? step.description.trim() - : "Authenticate with Touch ID or enter your password to continue."; + : t("step.auth.default"); const effectiveDescription = isAuthPlaceholder ? authDescription @@ -158,7 +160,7 @@ export default memo(function EditorStepCard({ return (
- )} @@ -292,14 +294,14 @@ export default memo(function EditorStepCard({ className="editor-step-ai" onClick={() => onGenerateDescription(step.id)} disabled={!aiEnabled || isGenerating || isAuthPlaceholder} - title={!aiEnabled ? "Enable Apple Intelligence descriptions in StepCast Settings" : isGenerating ? "Generating..." : "Generate with Apple Intelligence"} + title={!aiEnabled ? t("step.ai.button_disabled_title") : isGenerating ? t("step.ai.button_generating_title") : t("step.ai.button_default_title")} > - )}
diff --git a/src/components/EditorWindow.test.tsx b/src/components/EditorWindow.test.tsx index 73daef4..53085c5 100644 --- a/src/components/EditorWindow.test.tsx +++ b/src/components/EditorWindow.test.tsx @@ -5,6 +5,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import EditorWindow from "./EditorWindow"; import type { Step } from "../types/step"; +import { I18nProvider } from "../i18n"; let capturedDragEnd: ((event: any) => void) | null = null; @@ -55,6 +56,8 @@ describe("EditorWindow", () => { mockInvoke.mockReset(); mockListen.mockReset(); mockListen.mockResolvedValue(() => {}); + localStorage.clear(); + document.documentElement.removeAttribute("data-theme"); }); it("shows empty state when no steps", async () => { @@ -323,6 +326,7 @@ describe("EditorWindow", () => { await user.click(screen.getAllByTitle("Enable Apple Intelligence descriptions in StepCast Settings")[1]); expect(mockInvoke).not.toHaveBeenCalledWith("generate_step_descriptions", { stepIds: ["step-1"], + appLanguage: "en", }); act(() => { @@ -333,8 +337,11 @@ describe("EditorWindow", () => { }), ); }); - await user.click(screen.getByTitle("Generate with Apple Intelligence")); - expect(mockInvoke).toHaveBeenCalledWith("generate_step_descriptions", { stepIds: ["step-1"] }); + await user.click(screen.getByTitle(/Generate with Apple Intelligence|Mit Apple Intelligence generieren/)); + expect(mockInvoke).toHaveBeenCalledWith("generate_step_descriptions", { + stepIds: ["step-1"], + appLanguage: "en", + }); }); it("enhances missing descriptions first, then all when none are missing", async () => { @@ -353,7 +360,10 @@ describe("EditorWindow", () => { }); await user.click(screen.getByRole("button", { name: /Enhance Steps/i })); - expect(mockInvoke).toHaveBeenCalledWith("generate_step_descriptions", { mode: "missing_only" }); + expect(mockInvoke).toHaveBeenCalledWith("generate_step_descriptions", { + mode: "missing_only", + appLanguage: "en", + }); act(() => { listeners["steps-reordered"]({ @@ -364,7 +374,10 @@ describe("EditorWindow", () => { }); }); await user.click(screen.getByRole("button", { name: /Enhance Steps/i })); - expect(mockInvoke).toHaveBeenCalledWith("generate_step_descriptions", { mode: "all" }); + expect(mockInvoke).toHaveBeenCalledWith("generate_step_descriptions", { + mode: "all", + appLanguage: "en", + }); }); it("syncs AI toggle from storage and tauri event listeners", async () => { @@ -598,6 +611,7 @@ describe("EditorWindow", () => { expect(mockInvoke).toHaveBeenCalledWith("generate_step_descriptions", { stepIds: ["step-1", "step-2"], + appLanguage: "en", }); }); @@ -990,9 +1004,41 @@ describe("EditorWindow", () => { expect(screen.getByText("Clicked in Finder")).toBeInTheDocument(); }); - await user.click(screen.getByTitle("Generate with Apple Intelligence")); + await user.click( + screen.getByTitle(/Generate with Apple Intelligence|Mit Apple Intelligence generieren/), + ); + expect(mockInvoke).toHaveBeenCalledWith("generate_step_descriptions", { + stepIds: ["step-1"], + appLanguage: "en", + }); + }); + + it("uses updated language for AI generation after language-changed event", async () => { + const user = userEvent.setup(); + localStorage.setItem("appleIntelligenceDescriptions", "true"); + const listeners = captureListeners(); + mockInvoke.mockResolvedValueOnce([makeStep({ id: "step-1" })]); + mockInvoke.mockResolvedValue(undefined); + + render( + + + , + ); + await waitFor(() => { + expect(screen.getByText("Clicked in Finder")).toBeInTheDocument(); + }); + + act(() => { + listeners["language-changed"]({ payload: { language: "de" } }); + }); + + await user.click( + screen.getByTitle(/Generate with Apple Intelligence|Mit Apple Intelligence generieren/), + ); expect(mockInvoke).toHaveBeenCalledWith("generate_step_descriptions", { stepIds: ["step-1"], + appLanguage: "de", }); }); diff --git a/src/components/EditorWindow.tsx b/src/components/EditorWindow.tsx index 632bdd9..25c58b1 100644 --- a/src/components/EditorWindow.tsx +++ b/src/components/EditorWindow.tsx @@ -7,6 +7,7 @@ import EditorStepCard from "./EditorStepCard"; import UndoToast from "./UndoToast"; import type { BoundsPercent, Step } from "../types/step"; import { mergeUpdatedStep } from "../utils/stepEvents"; +import { isSupportedAppLanguage, type AppLanguage, useI18n } from "../i18n"; type PendingDelete = { step: Step; index: number; timerId: ReturnType }; @@ -18,6 +19,7 @@ function isAuthPlaceholder(step: Step) { } export default function EditorWindow() { + const { appLanguage, locale, setAppLanguage, t } = useI18n(); const [steps, setSteps] = useState([]); const [loaded, setLoaded] = useState(false); const [collapsedIds, setCollapsedIds] = useState>(new Set()); @@ -68,6 +70,29 @@ export default function EditorWindow() { }; }, []); + useEffect(() => { + let unlisten: UnlistenFn | null = null; + let cancelled = false; + listen<{ language?: AppLanguage }>("language-changed", (event) => { + const language = event.payload?.language; + if (language && isSupportedAppLanguage(language)) { + setAppLanguage(language); + } + }) + .then((fn) => { + if (cancelled) { + fn(); + } else { + unlisten = fn; + } + }) + .catch(() => {}); + return () => { + cancelled = true; + if (unlisten) unlisten(); + }; + }, [setAppLanguage]); + // Listen for step-captured, step-updated, steps-discarded events useEffect(() => { const unlisteners: UnlistenFn[] = []; @@ -208,9 +233,10 @@ export default function EditorWindow() { const handleGenerateDescription = useCallback( (stepId: string) => { if (!aiEnabled) return; - invoke("generate_step_descriptions", { stepIds: [stepId] }).catch(() => {}); + const aiLanguage = appLanguage === "system" ? locale : appLanguage; + invoke("generate_step_descriptions", { stepIds: [stepId], appLanguage: aiLanguage }).catch(() => {}); }, - [aiEnabled], + [aiEnabled, appLanguage, locale], ); const handleToggleCollapse = useCallback((stepId: string) => { @@ -266,11 +292,12 @@ export default function EditorWindow() { const handleBulkGenerate = useCallback(() => { if (!aiEnabled) return; + const aiLanguage = appLanguage === "system" ? locale : appLanguage; const ids = steps.filter((s) => selection.has(s.id)).map((s) => s.id); if (ids.length > 0) { - invoke("generate_step_descriptions", { stepIds: ids }).catch(() => {}); + invoke("generate_step_descriptions", { stepIds: ids, appLanguage: aiLanguage }).catch(() => {}); } - }, [aiEnabled, steps, selection]); + }, [aiEnabled, appLanguage, locale, selection, steps]); // Prune stale selection IDs when steps change useEffect(() => { @@ -326,6 +353,7 @@ export default function EditorWindow() { const handleEnhanceAll = useCallback(() => { if (!aiEnabled) return; + const aiLanguage = appLanguage === "system" ? locale : appLanguage; const missing = steps.filter( (s) => @@ -335,8 +363,8 @@ export default function EditorWindow() { s.description_source !== "manual", ); const mode = missing.length > 0 ? "missing_only" : "all"; - invoke("generate_step_descriptions", { mode }).catch(() => {}); - }, [aiEnabled, steps]); + invoke("generate_step_descriptions", { mode, appLanguage: aiLanguage }).catch(() => {}); + }, [aiEnabled, appLanguage, locale, steps]); const isAnyGenerating = useMemo( () => steps.some((s) => s.description_status === "generating"), @@ -349,34 +377,34 @@ export default function EditorWindow() {
-
Edit Steps
+
{t("editor.title")}
- {steps.length} step{steps.length === 1 ? "" : "s"} + {t("editor.subtitle_steps", { count: steps.length })}
{selection.size > 0 ? ( <> - {selection.size} selected + {t("editor.selection_count", { count: selection.size })} - - ) : ( @@ -384,16 +412,16 @@ export default function EditorWindow() { {isAnyGenerating && ( - Generating… + {t("editor.generating")} )} {steps.length > 5 && ( )} )} @@ -420,7 +448,7 @@ export default function EditorWindow() {
{steps.length === 0 ? ( -
No steps recorded yet
+
{t("editor.empty")}
) : ( s.id)} strategy={verticalListSortingStrategy}> @@ -451,7 +479,7 @@ export default function EditorWindow() {
{pendingDelete && ( diff --git a/src/components/ExportSheet.tsx b/src/components/ExportSheet.tsx index c4a2482..442be4c 100644 --- a/src/components/ExportSheet.tsx +++ b/src/components/ExportSheet.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useI18n } from "../i18n"; type ExportFormat = "html" | "md" | "pdf"; @@ -9,13 +10,10 @@ interface ExportSheetProps { onClose: () => void; } -const FORMAT_OPTIONS: { value: ExportFormat; label: string }[] = [ - { value: "html", label: "HTML" }, - { value: "md", label: "MD" }, - { value: "pdf", label: "PDF" }, -]; +const FORMAT_OPTIONS: ExportFormat[] = ["html", "md", "pdf"]; export default function ExportSheet({ stepCount, exporting, onExport, onClose }: ExportSheetProps) { + const { t } = useI18n(); const [title, setTitle] = useState("New StepCast Guide"); const [format, setFormat] = useState( () => (localStorage.getItem("exportFormat") as ExportFormat) || "pdf" @@ -31,48 +29,48 @@ export default function ExportSheet({ stepCount, exporting, onExport, onClose }: return (
e.stopPropagation()}> -

Export Guide

+

{t("export.title")}

- Format + {t("export.field.format")}
{FORMAT_OPTIONS.map((opt) => ( ))}
-
{stepCount} step{stepCount !== 1 ? "s" : ""}
+
{t("export.steps_count", { count: stepCount })}
diff --git a/src/components/ImageLightbox.tsx b/src/components/ImageLightbox.tsx index 9ff3cca..1f0a86f 100644 --- a/src/components/ImageLightbox.tsx +++ b/src/components/ImageLightbox.tsx @@ -1,3 +1,5 @@ +import { useI18n } from "../i18n"; + type ImageLightboxProps = { src: string; alt: string; @@ -5,9 +7,10 @@ type ImageLightboxProps = { }; export default function ImageLightbox({ src, alt, onClose }: ImageLightboxProps) { + const { t } = useI18n(); return (
- @@ -488,8 +500,8 @@ export default function RecorderPanel() { {/* Fallback hint when tray icon is hidden */} {!showNotch && (
- Tray icon hidden by another app — use Cmd+Shift+S to toggle - + +
)} @@ -497,13 +509,13 @@ export default function RecorderPanel() { {updateAvailable && (
- v{updateAvailable} available + {t("recorder.update.available", { version: updateAvailable })}
{updateNotes && } @@ -514,7 +526,7 @@ export default function RecorderPanel() { {whatsNew && !showWelcome && (
- Updated to v{whatsNew} + {t("recorder.update.updated_to", { version: whatsNew })}
@@ -533,25 +545,25 @@ export default function RecorderPanel() {
- Missing: {missingPermissions.join(", ")} + {t("recorder.permissions.missing", { list: missingPermissions.join(", ") })}
- Screen Recording + {t("recorder.permissions.screen_recording")} {permissions?.screen_recording ? ( - OK + {t("recorder.permissions.ok")} ) : ( )}
- Accessibility + {t("recorder.permissions.accessibility")} {permissions?.accessibility ? ( - OK + {t("recorder.permissions.ok")} ) : ( )}
@@ -573,9 +585,9 @@ export default function RecorderPanel() { disabled={!permissionsReady} > - Start Recording + {t("recorder.idle.start_recording")} -

Click anywhere on your screen to capture steps

+

{t("recorder.idle.hint")}

)} @@ -590,14 +602,14 @@ export default function RecorderPanel() { onClick={() => handleCommand("pause", "paused")} > - Pause + {t("recorder.controls.pause")} )} @@ -609,14 +621,14 @@ export default function RecorderPanel() { onClick={() => handleCommand("resume", "recording")} > - Resume + {t("recorder.controls.resume")} )} @@ -624,14 +636,14 @@ export default function RecorderPanel() {
-

Steps

+

{t("recorder.steps.title")}

- {steps.length} captured + {t("recorder.steps.captured_count", { count: steps.length })} {canDiscard && (
{steps.length === 0 ? (
- Click anywhere to capture steps. + {t("recorder.steps.empty")}
) : ( @@ -665,8 +677,8 @@ export default function RecorderPanel() {
-

Steps

- {steps.length} captured +

{t("recorder.steps.title")}

+ {t("recorder.steps.captured_count", { count: steps.length })}
s.id)} strategy={verticalListSortingStrategy}> @@ -687,7 +699,7 @@ export default function RecorderPanel() { onClick={() => setShowNewMenu((v) => !v)} > - New + {t("recorder.new.button")} {showNewMenu && ( <> @@ -698,14 +710,14 @@ export default function RecorderPanel() { disabled={!permissionsReady} > - New Recording + {t("recorder.new.recording")}
@@ -716,14 +728,14 @@ export default function RecorderPanel() { onClick={() => invoke("open_editor_window")} > - Edit + {t("recorder.action.edit")}
diff --git a/src/components/SettingsSheet.test.tsx b/src/components/SettingsSheet.test.tsx index acd9517..a57c5cf 100644 --- a/src/components/SettingsSheet.test.tsx +++ b/src/components/SettingsSheet.test.tsx @@ -7,6 +7,7 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import { check, type Update } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import SettingsSheet, { initTheme } from "./SettingsSheet"; +import { I18nProvider } from "../i18n"; const mockInvoke = vi.mocked(invoke); const mockEmit = vi.mocked(emit); @@ -29,7 +30,17 @@ function fakeUpdate(overrides: Partial = {}): Update { } as unknown as Update; } +function renderSettings(onBack = vi.fn()) { + return render( + + + , + ); +} + beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute("data-theme"); mockInvoke.mockReset(); mockEmit.mockReset(); mockOpenUrl.mockReset(); @@ -43,24 +54,32 @@ beforeEach(() => { describe("SettingsSheet", () => { it("renders settings header with back button", () => { - render(); + renderSettings(); expect(screen.getByText("Settings")).toBeInTheDocument(); }); it("defaults theme to system", () => { - render(); - expect(screen.getByText("System").className).toContain("active"); + renderSettings(); + expect(screen.getByRole("button", { name: "System" }).className).toContain("active"); }); it("loads saved theme from localStorage", () => { localStorage.setItem("theme", "dark"); - render(); + renderSettings(); expect(screen.getByText("Dark").className).toContain("active"); }); + it("changes app language and emits sync event", async () => { + const user = userEvent.setup(); + renderSettings(); + await user.selectOptions(screen.getByRole("combobox", { name: "Language" }), "de"); + expect(localStorage.getItem("appLanguage")).toBe("de"); + expect(mockEmit).toHaveBeenCalledWith("language-changed", { language: "de" }); + }); + it("sets data-theme and localStorage when selecting a theme", async () => { const user = userEvent.setup(); - render(); + renderSettings(); await user.click(screen.getByText("Dark")); expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); expect(localStorage.getItem("theme")).toBe("dark"); @@ -70,21 +89,21 @@ describe("SettingsSheet", () => { const user = userEvent.setup(); document.documentElement.setAttribute("data-theme", "dark"); localStorage.setItem("theme", "dark"); - render(); - await user.click(screen.getByText("System")); + renderSettings(); + await user.click(screen.getByRole("button", { name: "System" })); expect(document.documentElement.hasAttribute("data-theme")).toBe(false); expect(localStorage.getItem("theme")).toBe("system"); }); it("shows version number from Tauri API", async () => { - render(); + renderSettings(); expect(await screen.findByText("Version 0.2.0")).toBeInTheDocument(); }); it("checks for updates and shows up-to-date", async () => { const user = userEvent.setup(); mockCheck.mockResolvedValue(null); - render(); + renderSettings(); await user.click(screen.getByText("Check for Updates")); expect(await screen.findByText("You're up to date")).toBeInTheDocument(); }); @@ -92,7 +111,7 @@ describe("SettingsSheet", () => { it("shows available update with install button", async () => { const user = userEvent.setup(); mockCheck.mockResolvedValue(fakeUpdate()); - render(); + renderSettings(); await user.click(screen.getByText("Check for Updates")); expect(await screen.findByText("Install v1.2.0")).toBeInTheDocument(); }); @@ -103,7 +122,7 @@ describe("SettingsSheet", () => { mockCheck.mockResolvedValue( fakeUpdate({ downloadAndInstall: mockDownloadAndInstall }), ); - render(); + renderSettings(); // First check for updates await user.click(screen.getByText("Check for Updates")); @@ -117,7 +136,7 @@ describe("SettingsSheet", () => { it("shows error when update check fails", async () => { const user = userEvent.setup(); mockCheck.mockRejectedValue(new Error("Network error")); - render(); + renderSettings(); await user.click(screen.getByText("Check for Updates")); expect( await screen.findByText("Could not check for updates"), @@ -126,26 +145,26 @@ describe("SettingsSheet", () => { it("calls onBack when Escape is pressed", () => { const onBack = vi.fn(); - render(); + renderSettings(onBack); fireEvent.keyDown(window, { key: "Escape" }); expect(onBack).toHaveBeenCalled(); }); it("renders GitHub and bug report links", () => { - render(); + renderSettings(); expect(screen.getByText("GitHub")).toBeInTheDocument(); expect(screen.getByText("Report a Bug")).toBeInTheDocument(); }); it("renders Apple-style switch control for Apple Intelligence toggle", () => { - render(); + renderSettings(); const toggle = screen.getByLabelText("Apple Intelligence step descriptions"); expect(toggle).toHaveAttribute("role", "switch"); }); it("toggles Apple Intelligence via row click and emits sync event", async () => { const user = userEvent.setup(); - render(); + renderSettings(); const row = screen.getByLabelText("Toggle Apple Intelligence step descriptions"); await user.click(row); expect(localStorage.getItem("appleIntelligenceDescriptions")).toBe("true"); @@ -153,7 +172,7 @@ describe("SettingsSheet", () => { }); it("toggles Apple Intelligence via keyboard (Enter and Space)", () => { - render(); + renderSettings(); const row = screen.getByLabelText("Toggle Apple Intelligence step descriptions"); fireEvent.keyDown(row, { key: "Enter" }); expect(localStorage.getItem("appleIntelligenceDescriptions")).toBe("true"); @@ -163,13 +182,13 @@ describe("SettingsSheet", () => { it("shows eligibility fallback when response shape is invalid", async () => { mockInvoke.mockResolvedValue({ foo: "bar" }); - render(); + renderSettings(); expect(await screen.findByText(/Could not check system eligibility\./)).toBeInTheDocument(); }); it("shows eligibility fallback when eligibility call fails", async () => { mockInvoke.mockRejectedValue(new Error("boom")); - render(); + renderSettings(); expect(await screen.findByText(/Could not check system eligibility\./)).toBeInTheDocument(); }); @@ -178,7 +197,7 @@ describe("SettingsSheet", () => { mockOpenUrl .mockRejectedValueOnce(new Error("primary fail")) .mockResolvedValueOnce(); - render(); + renderSettings(); await user.click(screen.getByText("Open Apple Intelligence & Siri Settings")); expect(mockOpenUrl).toHaveBeenNthCalledWith( 1, @@ -193,14 +212,14 @@ describe("SettingsSheet", () => { it("swallows errors when both settings URLs fail", async () => { const user = userEvent.setup(); mockOpenUrl.mockRejectedValue(new Error("all fail")); - render(); + renderSettings(); await user.click(screen.getByText("Open Apple Intelligence & Siri Settings")); expect(mockOpenUrl).toHaveBeenCalledTimes(2); }); it("opens About links", async () => { const user = userEvent.setup(); - render(); + renderSettings(); await user.click(screen.getByText("GitHub")); await user.click(screen.getByText("Report a Bug")); expect(mockOpenUrl).toHaveBeenCalledWith("https://github.com/w0nk1/StepCast"); @@ -212,7 +231,7 @@ describe("SettingsSheet", () => { mockCheck .mockResolvedValueOnce(fakeUpdate()) .mockRejectedValueOnce(new Error("install check fail")); - render(); + renderSettings(); await user.click(screen.getByText("Check for Updates")); await user.click(await screen.findByText("Install v1.2.0")); expect(await screen.findByText("Could not check for updates")).toBeInTheDocument(); diff --git a/src/components/SettingsSheet.tsx b/src/components/SettingsSheet.tsx index 2a510a8..76be344 100644 --- a/src/components/SettingsSheet.tsx +++ b/src/components/SettingsSheet.tsx @@ -5,6 +5,7 @@ import { emit } from "@tauri-apps/api/event"; import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { isSupportedAppLanguage, useI18n } from "../i18n"; type Theme = "light" | "dark" | "system"; type UpdateStatus = "idle" | "checking" | "available" | "installing" | "up-to-date" | "error"; @@ -14,11 +15,7 @@ interface SettingsSheetProps { onBack: () => void; } -const THEME_OPTIONS: { value: Theme; label: string }[] = [ - { value: "light", label: "Light" }, - { value: "dark", label: "Dark" }, - { value: "system", label: "System" }, -]; +const THEME_OPTIONS: Theme[] = ["light", "dark", "system"]; const APPLE_INTELLIGENCE_SETTINGS_URL = "x-apple.systempreferences:com.apple.Siri-Settings.extension"; const SIRI_SETTINGS_FALLBACK_URL = "x-apple.systempreferences:com.apple.preference.siri"; @@ -66,6 +63,7 @@ export function initTheme() { } export default function SettingsSheet({ onBack }: SettingsSheetProps) { + const { appLanguage, availableLocales, setAppLanguage, getLanguageLabel, t } = useI18n(); const [theme, setTheme] = useState( () => (localStorage.getItem("theme") as Theme) || "system" ); @@ -86,17 +84,21 @@ export default function SettingsSheet({ onBack }: SettingsSheetProps) { emit("ai-toggle-changed", { enabled: aiEnabled }).catch(() => {}); }, [aiEnabled]); + useEffect(() => { + emit("language-changed", { language: appLanguage }).catch(() => {}); + }, [appLanguage]); + useEffect(() => { invoke("get_apple_intelligence_eligibility") .then((result) => { if (result && typeof result.eligible === "boolean" && typeof result.reason === "string") { setAiEligibility(result); } else { - setAiEligibility({ eligible: false, reason: "Could not check system eligibility." }); + setAiEligibility({ eligible: false, reason: t("settings.ai.eligibility.fallback") }); } }) - .catch(() => setAiEligibility({ eligible: false, reason: "Could not check system eligibility." })); - }, []); + .catch(() => setAiEligibility({ eligible: false, reason: t("settings.ai.eligibility.fallback") })); + }, [t]); const selectTheme = useCallback((t: Theme) => { setTheme(t); @@ -155,26 +157,50 @@ export default function SettingsSheet({ onBack }: SettingsSheetProps) { <> {/* Header with back button */}
-
+ {/* Language */} +
+
+
{t("settings.language.label")}
+ +
+
+ {/* Appearance */}
-
Appearance
+
{t("settings.appearance.label")}
{THEME_OPTIONS.map((opt) => ( ))}
@@ -184,13 +210,13 @@ export default function SettingsSheet({ onBack }: SettingsSheetProps) { {/* Updates */}
-
Updates
+
{t("settings.updates.label")}
- Version {appVersion} + {t("settings.version", { version: appVersion })}
{updateStatus === "available" && updateVersion ? ( ) : ( )} {updateStatus === "up-to-date" && ( - You're up to date + {t("settings.updates.up_to_date")} )} {updateStatus === "error" && ( - Could not check for updates + {t("settings.updates.error")} )}
@@ -243,7 +273,7 @@ export default function SettingsSheet({ onBack }: SettingsSheetProps) { d="M18.5 14l1.05 3.2 3.2 1.05-3.2 1.05-1.05 3.2-1.05-3.2-3.2-1.05 3.2-1.05L18.5 14z" /> - Apple Intelligence + {t("settings.ai.title")}
!v); } }} - aria-label="Toggle Apple Intelligence step descriptions" + aria-label={t("settings.ai.toggle_row_aria")} > - Use for step descriptions + {t("settings.ai.use_for_steps")}
{aiEligibility ? aiEligibility.eligible - ? "No extra permissions. Requires Apple Intelligence enabled in System Settings. Runs on-device (offline). Step data stays on your Mac." - : `${aiEligibility.reason} No extra permissions. StepCast will use built-in descriptions. Step data stays on your Mac.` - : "Checking eligibility..."} + ? t("settings.ai.eligibility.ok") + : t("settings.ai.eligibility.with_reason", { reason: aiEligibility.reason }) + : t("settings.ai.eligibility.checking")}
{aiEligibility?.details &&
{aiEligibility.details}
}
@@ -282,14 +312,14 @@ export default function SettingsSheet({ onBack }: SettingsSheetProps) { {/* About */}
-
About
+
{t("settings.about.label")}
diff --git a/src/components/StepItem.tsx b/src/components/StepItem.tsx index 420c2c1..07dcfb9 100644 --- a/src/components/StepItem.tsx +++ b/src/components/StepItem.tsx @@ -4,6 +4,7 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { Step } from "../types/step"; import { getCroppedImageStyles, markerPositionForStep } from "../utils/stepCrop"; +import { useI18n } from "../i18n"; type StepItemProps = { step: Step; @@ -13,6 +14,7 @@ type StepItemProps = { }; export default memo(function StepItem({ step, index, onDelete, sortable }: StepItemProps) { + const { t } = useI18n(); const [confirming, setConfirming] = useState(false); const [thumbRetry, setThumbRetry] = useState(0); const buttonRef = useRef(null); @@ -69,15 +71,15 @@ export default memo(function StepItem({ step, index, onDelete, sortable }: StepI // Action description const actionDesc = step.action === "DoubleClick" - ? "Double-clicked in" + ? t("step.action.double_clicked_in") : step.action === "RightClick" - ? "Right-clicked in" - : "Clicked in"; + ? t("step.action.right_clicked_in") + : t("step.action.clicked_in"); const authDescription = step.description && step.description.trim().length > 0 ? step.description.trim() - : "Authenticate with Touch ID or enter your password to continue."; + : t("step.auth.default"); const description = isAuthPlaceholder ? authDescription @@ -101,7 +103,7 @@ export default memo(function StepItem({ step, index, onDelete, sortable }: StepI return (
{sortable && ( - -
); diff --git a/src/editor.tsx b/src/editor.tsx index 87e667b..ede8a2e 100644 --- a/src/editor.tsx +++ b/src/editor.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import EditorWindow from "./components/EditorWindow"; import { initTheme } from "./components/SettingsSheet"; +import { I18nProvider } from "./i18n"; import "./App.css"; import "./editor.css"; import "react-image-crop/dist/ReactCrop.css"; @@ -10,6 +11,8 @@ initTheme(); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + , ); diff --git a/src/i18n/index.test.tsx b/src/i18n/index.test.tsx new file mode 100644 index 0000000..a6eb1bf --- /dev/null +++ b/src/i18n/index.test.tsx @@ -0,0 +1,242 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + I18nProvider, + isSupportedAppLanguage, + readAppLanguage, + resolveLocale, + useI18n, + writeAppLanguage, +} from "./index"; + +const originalDisplayNames = Intl.DisplayNames; + +function Probe(props: { keyName: string; count?: string | number; name?: string }) { + const { appLanguage, availableLocales, getLanguageLabel, locale, setAppLanguage, t } = useI18n(); + return ( +
+ + + + {t(props.keyName, { count: props.count, name: props.name })} + + {appLanguage} + {locale} + {availableLocales.join(",")} + {getLanguageLabel("system")} + {getLanguageLabel("de")} + {t("i18n.unknown.key")} +
+ ); +} + +function FallbackProbe() { + const { getLanguageLabel, t } = useI18n(); + return ( +
+ {getLanguageLabel("system")} + {t("test.hello")} +
+ ); +} + +beforeEach(() => { + localStorage.clear(); + Object.defineProperty(navigator, "languages", { + configurable: true, + value: ["en-US"], + }); + Object.defineProperty(navigator, "language", { + configurable: true, + value: "en-US", + }); + Object.defineProperty(Intl, "DisplayNames", { + configurable: true, + value: originalDisplayNames, + }); +}); + +describe("i18n", () => { + it("resolves english text by default", () => { + render( + + + , + ); + expect(screen.getByTestId("value").textContent).toBe("Hello"); + expect(screen.getByTestId("locales").textContent).toBe("en,de"); + }); + + it("switches locale and falls back to english when key is missing in target locale", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await user.click(screen.getByRole("button", { name: "de" })); + expect(screen.getByTestId("value").textContent).toBe("Only English"); + expect(screen.getByTestId("app-language").textContent).toBe("de"); + }); + + it("supports interpolation, pluralization, and missing key fallback", () => { + render( + + + , + ); + expect(screen.getByTestId("value").textContent).toBe("Sam has 2 steps"); + expect(screen.getByTestId("missing-key").textContent).toBe("i18n.unknown.key"); + }); + + it("handles string and missing interpolation values", () => { + const { rerender } = render( + + + , + ); + expect(screen.getByTestId("value").textContent).toBe("Sam has 2 steps"); + + rerender( + + + , + ); + expect(screen.getByTestId("value").textContent).toContain("0 steps"); + }); + + it("synchronizes language changes from storage events", () => { + render( + + + , + ); + expect(screen.getByTestId("app-language").textContent).toBe("system"); + + act(() => { + localStorage.setItem("appLanguage", "de"); + window.dispatchEvent(new StorageEvent("storage", { key: "not_app_language" })); + }); + expect(screen.getByTestId("app-language").textContent).toBe("system"); + + act(() => { + localStorage.setItem("appLanguage", "de"); + window.dispatchEvent(new StorageEvent("storage", { key: "appLanguage" })); + }); + expect(screen.getByTestId("app-language").textContent).toBe("de"); + }); + + it("falls back to code labels when Intl.DisplayNames is unavailable", () => { + Object.defineProperty(Intl, "DisplayNames", { + configurable: true, + value: undefined, + }); + render( + + + , + ); + expect(screen.getByTestId("label-de").textContent).toBe("de"); + }); + + it("falls back to UI-locale language names when self-locale name is unavailable", () => { + class DisplayNamesWithFallback { + private locale: string; + constructor(locales: string[]) { + this.locale = locales[0] ?? "en"; + } + of(code: string): string | undefined { + if (this.locale === code) return undefined; + return `lang-${code}`; + } + } + Object.defineProperty(Intl, "DisplayNames", { + configurable: true, + value: DisplayNamesWithFallback, + }); + + render( + + + , + ); + expect(screen.getByTestId("label-de").textContent).toBe("lang-de"); + }); + + it("uses fallback context when provider is missing", () => { + render(); + expect(screen.getByTestId("fallback-label").textContent).toBe("System"); + expect(screen.getByTestId("fallback-text").textContent).toBe("Hello"); + }); + + it("normalizes and validates app language persistence", () => { + writeAppLanguage("DE" as never); + expect(localStorage.getItem("appLanguage")).toBe("de"); + expect(readAppLanguage()).toBe("de"); + + localStorage.setItem("appLanguage", "system"); + expect(readAppLanguage()).toBe("system"); + + localStorage.setItem("appLanguage", "fr"); + expect(readAppLanguage()).toBe("system"); + + writeAppLanguage("system"); + expect(localStorage.getItem("appLanguage")).toBe("system"); + }); + + it("resolves locales from direct, base, and fallback system values", () => { + Object.defineProperty(navigator, "languages", { + configurable: true, + value: ["de", "fr-FR"], + }); + expect(resolveLocale("system")).toBe("de"); + + Object.defineProperty(navigator, "languages", { + configurable: true, + value: ["fr-FR", "de-DE"], + }); + expect(resolveLocale("system")).toBe("de"); + + Object.defineProperty(navigator, "languages", { + configurable: true, + value: ["fr-FR"], + }); + expect(resolveLocale("system")).toBe("en"); + + Object.defineProperty(navigator, "languages", { + configurable: true, + value: [], + }); + Object.defineProperty(navigator, "language", { + configurable: true, + value: "de-DE", + }); + expect(resolveLocale("system")).toBe("de"); + + expect(resolveLocale(" DE " as never)).toBe("de"); + expect(resolveLocale("fr" as never)).toBe("en"); + }); + + it("validates supported app language values", () => { + expect(isSupportedAppLanguage("system")).toBe(true); + expect(isSupportedAppLanguage("de")).toBe(true); + expect(isSupportedAppLanguage("en")).toBe(true); + expect(isSupportedAppLanguage("fr")).toBe(false); + }); + + it("uses safe fallbacks when browser globals are unavailable", () => { + const originalNavigator = globalThis.navigator; + const originalStorage = globalThis.localStorage; + + vi.stubGlobal("navigator", undefined); + vi.stubGlobal("localStorage", undefined); + + expect(resolveLocale("system")).toBe("en"); + expect(readAppLanguage()).toBe("system"); + expect(() => writeAppLanguage("de")).not.toThrow(); + + vi.stubGlobal("navigator", originalNavigator); + vi.stubGlobal("localStorage", originalStorage); + }); +}); diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx new file mode 100644 index 0000000..8815164 --- /dev/null +++ b/src/i18n/index.tsx @@ -0,0 +1,209 @@ +import { + createContext, + type PropsWithChildren, + useEffect, + useContext, + useMemo, + useState, +} from "react"; +type Catalog = Record; +type CatalogModule = { default: Catalog }; + +export type Locale = string; +export type AppLanguage = "system" | Locale; +type Vars = Record; + +const APP_LANGUAGE_KEY = "appLanguage"; +const localeModules = import.meta.glob("./locales/*.json", { + eager: true, +}); + +function localeCodeFromPath(path: string): string | null { + const match = path.match(/\/([^/]+)\.json$/); + if (!match) return null; + return match[1].toLowerCase(); +} + +const catalogs = Object.fromEntries( + Object.entries(localeModules) + .map(([path, module]) => { + const code = localeCodeFromPath(path); + if (!code) return null; + return [code, module.default] as const; + }) + .filter((entry): entry is readonly [string, Catalog] => entry !== null), +); + +const localeCodes = Object.keys(catalogs).sort((a, b) => { + if (a === "en") return -1; + if (b === "en") return 1; + return a.localeCompare(b); +}); +const localeCodeSet = new Set(localeCodes); +const DEFAULT_LOCALE: Locale = localeCodeSet.has("en") + ? "en" + : (localeCodes[0] ?? "en"); + +type I18nContextValue = { + appLanguage: AppLanguage; + locale: Locale; + availableLocales: Locale[]; + setAppLanguage: (language: AppLanguage) => void; + getLanguageLabel: (language: AppLanguage) => string; + t: (key: string, vars?: Vars) => string; +}; + +const FALLBACK_CONTEXT: I18nContextValue = { + appLanguage: "system", + locale: DEFAULT_LOCALE, + availableLocales: [DEFAULT_LOCALE], + setAppLanguage: () => {}, + getLanguageLabel: (language: AppLanguage) => + language === "system" ? "System" : language, + t: (key: string, vars?: Vars) => translate(DEFAULT_LOCALE, key, vars), +}; + +const I18nContext = createContext(FALLBACK_CONTEXT); + +function isLocale(value: string): value is Locale { + return localeCodeSet.has(value.toLowerCase()); +} + +function normalizeLocaleCode(value: string): string { + return value.trim().toLowerCase(); +} + +export function isSupportedAppLanguage(value: string): value is AppLanguage { + return value === "system" || isLocale(value); +} + +function resolveBestLocale(candidates: string[]): Locale { + for (const candidate of candidates) { + const normalized = normalizeLocaleCode(candidate); + if (isLocale(normalized)) return normalized; + } + for (const candidate of candidates) { + const normalized = normalizeLocaleCode(candidate); + const base = normalized.split("-")[0]; + if (isLocale(base)) return base; + } + return DEFAULT_LOCALE; +} + +function detectSystemLocale(): Locale { + if (typeof navigator === "undefined") return DEFAULT_LOCALE; + const langs = Array.isArray(navigator.languages) && navigator.languages.length > 0 + ? navigator.languages + : [navigator.language]; + return resolveBestLocale(langs.filter(Boolean)); +} + +export function resolveLocale(language: AppLanguage): Locale { + if (language === "system") return detectSystemLocale(); + const normalized = normalizeLocaleCode(language); + return isLocale(normalized) ? normalized : DEFAULT_LOCALE; +} + +export function readAppLanguage(): AppLanguage { + const raw = + typeof localStorage !== "undefined" ? localStorage.getItem(APP_LANGUAGE_KEY) : null; + if (raw === "system") return "system"; + if (raw) { + const normalized = normalizeLocaleCode(raw); + if (isLocale(normalized)) return normalized; + } + return "system"; +} + +export function writeAppLanguage(language: AppLanguage) { + if (typeof localStorage === "undefined") return; + localStorage.setItem( + APP_LANGUAGE_KEY, + language === "system" ? language : normalizeLocaleCode(language), + ); +} + +function displayLanguageName(code: string, uiLocale: Locale): string { + if (typeof Intl !== "undefined" && typeof Intl.DisplayNames === "function") { + try { + const selfDisplay = new Intl.DisplayNames([code], { type: "language" }); + const self = selfDisplay.of(code); + if (self) return self; + } catch { + // Ignore and try ui-locale fallback. + } + try { + const uiDisplay = new Intl.DisplayNames([uiLocale], { type: "language" }); + const uiName = uiDisplay.of(code); + if (uiName) return uiName; + } catch { + // Ignore and use fallback. + } + } + return code; +} + +function formatMessage(template: string, vars: Vars = {}): string { + const pluralized = template.replace( + /\{(\w+),\s*plural,\s*one\s*\{([^{}]*)\}\s*other\s*\{([^{}]*)\}\s*\}/g, + (_match, varName: string, one: string, other: string) => { + const countRaw = vars[varName]; + const count = typeof countRaw === "number" ? countRaw : Number(countRaw ?? 0); + const selected = count === 1 ? one : other; + return selected.replace(/#/g, String(count)); + }, + ); + return pluralized.replace(/\{(\w+)\}/g, (_match, varName: string) => { + const value = vars[varName]; + return value == null ? "" : String(value); + }); +} + +function translate(locale: Locale, key: string, vars?: Vars): string { + const inLocale = catalogs[locale]?.[key]; + const inEnglish = catalogs.en?.[key]; + const inDefault = catalogs[DEFAULT_LOCALE]?.[key]; + const template = inLocale ?? inEnglish ?? inDefault ?? key; + return formatMessage(template, vars); +} + +export function I18nProvider({ children }: PropsWithChildren) { + const [appLanguage, setAppLanguageState] = useState(() => readAppLanguage()); + const locale = resolveLocale(appLanguage); + + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key !== APP_LANGUAGE_KEY) return; + setAppLanguageState(readAppLanguage()); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + + const value = useMemo( + () => ({ + appLanguage, + locale, + availableLocales: localeCodes, + setAppLanguage: (language: AppLanguage) => { + writeAppLanguage(language); + setAppLanguageState(language); + }, + getLanguageLabel: (language: AppLanguage) => { + if (language === "system") { + const systemLocale = detectSystemLocale(); + return `${translate(locale, "settings.language.system")} (${displayLanguageName(systemLocale, locale)})`; + } + return displayLanguageName(language, locale); + }, + t: (key: string, vars?: Vars) => translate(locale, key, vars), + }), + [appLanguage, locale], + ); + + return {children}; +} + +export function useI18n(): I18nContextValue { + return useContext(I18nContext); +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json new file mode 100644 index 0000000..e199af2 --- /dev/null +++ b/src/i18n/locales/de.json @@ -0,0 +1,151 @@ +{ + "app.name": "StepCast", + "common.cancel": "Abbrechen", + "common.dismiss": "Schließen", + "common.delete": "Löschen", + "common.undo": "Rückgängig", + "common.apply": "Anwenden", + "common.reset": "Zurücksetzen", + "common.export": "Exportieren", + "common.exporting": "Exportiere...", + "status.ready": "Bereit", + "status.recording": "Aufnahme läuft", + "status.paused": "Pausiert", + "status.stopped": "Gestoppt", + "settings.title": "Einstellungen", + "settings.back_title": "Zurück", + "settings.language.label": "Sprache", + "settings.language.system": "System", + "settings.appearance.label": "Darstellung", + "settings.theme.light": "Hell", + "settings.theme.dark": "Dunkel", + "settings.theme.system": "System", + "settings.updates.label": "Updates", + "settings.version": "Version {version}", + "settings.updates.check": "Nach Updates suchen", + "settings.updates.checking": "Prüfe...", + "settings.updates.installing": "Installiere...", + "settings.updates.install_version": "v{version} installieren", + "settings.updates.up_to_date": "Du bist auf dem neuesten Stand", + "settings.updates.error": "Updates konnten nicht geprüft werden", + "settings.about.label": "Über", + "settings.about.github": "GitHub", + "settings.about.report_bug": "Bug melden", + "settings.ai.title": "Apple Intelligence", + "settings.ai.toggle_row_aria": "Apple-Intelligence-Schrittbeschreibungen umschalten", + "settings.ai.toggle_aria": "Apple-Intelligence-Schrittbeschreibungen", + "settings.ai.use_for_steps": "Für Schrittbeschreibungen verwenden", + "settings.ai.eligibility.fallback": "System-Kompatibilität konnte nicht geprüft werden.", + "settings.ai.eligibility.ok": "Keine zusätzlichen Berechtigungen. Apple Intelligence muss in den Systemeinstellungen aktiviert sein. Läuft lokal auf dem Gerät (offline). Schrittdaten bleiben auf deinem Mac.", + "settings.ai.eligibility.with_reason": "{reason} Keine zusätzlichen Berechtigungen. StepCast nutzt integrierte Beschreibungen. Schrittdaten bleiben auf deinem Mac.", + "settings.ai.eligibility.checking": "Prüfe Kompatibilität...", + "settings.ai.open_settings": "Apple Intelligence & Siri Einstellungen öffnen", + "recorder.header.settings_title": "Einstellungen", + "recorder.fallback_hint": "Tray-Icon von anderer App verborgen — nutze Cmd+Shift+S zum Umschalten", + "recorder.fallback_dismiss_title": "Schließen", + "recorder.update.available": "v{version} verfügbar", + "recorder.update.install": "Installieren", + "recorder.update.updating": "Aktualisiere...", + "recorder.update.updated_to": "Aktualisiert auf v{version}", + "recorder.permissions.missing": "Fehlt: {list}", + "recorder.permissions.screen_recording": "Bildschirmaufnahme", + "recorder.permissions.accessibility": "Bedienungshilfen", + "recorder.permissions.ok": "OK", + "recorder.permissions.open_settings": "Einstellungen öffnen", + "recorder.idle.start_recording": "Aufnahme starten", + "recorder.idle.hint": "Klicke irgendwo auf deinen Bildschirm, um Schritte zu erfassen", + "recorder.controls.pause": "Pause", + "recorder.controls.resume": "Fortsetzen", + "recorder.controls.stop": "Stopp", + "recorder.steps.title": "Schritte", + "recorder.steps.captured_count": "{count} erfasst", + "recorder.steps.empty": "Klicke irgendwo, um Schritte zu erfassen.", + "recorder.discard.dialog_title": "Aufnahme verwerfen", + "recorder.discard.dialog_body": "Möchtest du wirklich {count} erfasste {count, plural, one {Schritt} other {Schritte}} verwerfen? Das kann nicht rückgängig gemacht werden.", + "recorder.discard.dialog_ok": "Verwerfen", + "recorder.discard.dialog_cancel": "Abbrechen", + "recorder.new.dialog_title": "Neue Aufnahme", + "recorder.new.dialog_body": "Beim Start einer neuen Aufnahme werden {count} erfasste {count, plural, one {Schritt} other {Schritte}} verworfen. Fortfahren?", + "recorder.new.dialog_ok": "Verwerfen & aufnehmen", + "recorder.new.button": "Neu", + "recorder.new.recording": "Neue Aufnahme", + "recorder.new.discard_all": "Alle verwerfen", + "recorder.action.edit": "Bearbeiten", + "recorder.action.export": "Exportieren", + "recorder.discard_title": "Aufnahme verwerfen", + "recorder.error.permissions_required": "Gewähre Bildschirmaufnahme- und Bedienungshilfen-Berechtigungen für die Aufnahme.", + "export.title": "Anleitung exportieren", + "export.field.title": "Titel", + "export.field.format": "Format", + "export.placeholder.title": "Titel der Anleitung...", + "export.steps_count": "{count, plural, one {# Schritt} other {# Schritte}}", + "export.format.html": "HTML", + "export.format.md": "MD", + "export.format.pdf": "PDF", + "welcome.title": "Willkommen bei StepCast", + "welcome.tip.menu_bar": "Klicke auf das Menüleisten-Icon, um dieses Panel zu öffnen", + "welcome.tip.shortcut": "Drücke Cmd+Shift+S von überall", + "welcome.tip.right_click": "Rechtsklick auf das Icon für weitere Optionen", + "welcome.tip.ai": "Optional: Apple-Intelligence-Beschreibungen (on-device, macOS 26+, kein Upload von Schritten)", + "welcome.dismiss": "Verstanden", + "editor.title": "Schritte bearbeiten", + "editor.subtitle_steps": "{count} {count, plural, one {Schritt} other {Schritte}}", + "editor.selection_count": "{count} ausgewählt", + "editor.enhance_selected": "Verbessern", + "editor.delete_selected": "Löschen", + "editor.deselect_all": "Auswahl aufheben", + "editor.generating": "Generiere…", + "editor.expand_all": "Alle ausklappen", + "editor.collapse_all": "Alle einklappen", + "editor.expand_all_title": "Alle Schritte ausklappen", + "editor.collapse_all_title": "Alle Schritte einklappen", + "editor.enhance_steps": "Schritte verbessern", + "editor.enhance_disabled_title": "Apple-Intelligence-Beschreibungen in den StepCast-Einstellungen aktivieren", + "editor.enhance_generating_title": "AI-Generierung läuft…", + "editor.enhance_default_title": "Kurze Beschreibungen für Schritte erzeugen", + "editor.empty": "Noch keine Schritte aufgezeichnet", + "editor.undo.step_deleted": "Schritt gelöscht", + "step.image_alt": "Schritt {num}", + "step.full_image_alt": "Schritt {num} volle Größe", + "step.number": "Schritt {num}", + "step.action.clicked_in": "Geklickt in", + "step.action.double_clicked_in": "Doppelt geklickt in", + "step.action.right_clicked_in": "Rechts geklickt in", + "step.action.shortcut_in": "Tastenkürzel verwendet in", + "step.auth.default": "Authentifiziere dich mit Touch ID oder gib dein Passwort ein, um fortzufahren.", + "step.drag_reorder_title": "Zum Neuordnen ziehen", + "step.delete.confirm_title": "Löschen bestätigen", + "step.delete.remove_title": "Schritt entfernen", + "step.badge.double_click": "Doppelklick", + "step.badge.right_click": "Rechtsklick", + "step.badge.shortcut": "Tastenkürzel", + "step.select": "Schritt auswählen", + "step.deselect": "Schritt abwählen", + "step.expand": "Schritt ausklappen", + "step.collapse": "Schritt einklappen", + "step.description.placeholder": "Schrittbeschreibung...", + "step.description.edit_title": "Beschreibung bearbeiten", + "step.note.placeholder": "Notiz hinzufügen...", + "step.note.button_default": "Notiz hinzufügen...", + "step.crop.adjust_title": "Sichtbaren Screenshot-Bereich anpassen", + "step.crop.adjusted_title": "Zugeschnitten — klicken zum Anpassen", + "step.ai.generating_pill": "AI…", + "step.ai.generated_pill": "AI", + "step.ai.failed_pill": "AI!", + "step.ai.failed_default": "Apple-Intelligence-Generierung fehlgeschlagen", + "step.ai.retry": "Erneut", + "step.ai.retry_enabled_title": "AI-Generierung erneut versuchen", + "step.ai.retry_disabled_title": "Apple Intelligence in Einstellungen aktivieren", + "step.ai.button_disabled_title": "Apple-Intelligence-Beschreibungen in den StepCast-Einstellungen aktivieren", + "step.ai.button_generating_title": "Generiere...", + "step.ai.button_default_title": "Mit Apple Intelligence generieren", + "step.ai.generating_title": "Generiere mit Apple Intelligence...", + "step.crop.modal_title": "Fokus-Zuschnitt anpassen", + "step.crop.close_title": "Crop-Editor schließen", + "step.crop.image_alt": "Zuschnitt für Schritt {num} anpassen", + "step.crop.view_full_size": "In voller Größe anzeigen", + "step.lightbox.close_title": "Lightbox schließen", + "test.hello": "Hallo", + "test.only_en": "Only English", + "test.count": "{name} hat {count, plural, one {# Schritt} other {# Schritte}}" +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json new file mode 100644 index 0000000..d463ba7 --- /dev/null +++ b/src/i18n/locales/en.json @@ -0,0 +1,151 @@ +{ + "app.name": "StepCast", + "common.cancel": "Cancel", + "common.dismiss": "Dismiss", + "common.delete": "Delete", + "common.undo": "Undo", + "common.apply": "Apply", + "common.reset": "Reset", + "common.export": "Export", + "common.exporting": "Exporting...", + "status.ready": "Ready", + "status.recording": "Recording", + "status.paused": "Paused", + "status.stopped": "Stopped", + "settings.title": "Settings", + "settings.back_title": "Back", + "settings.language.label": "Language", + "settings.language.system": "System", + "settings.appearance.label": "Appearance", + "settings.theme.light": "Light", + "settings.theme.dark": "Dark", + "settings.theme.system": "System", + "settings.updates.label": "Updates", + "settings.version": "Version {version}", + "settings.updates.check": "Check for Updates", + "settings.updates.checking": "Checking...", + "settings.updates.installing": "Installing...", + "settings.updates.install_version": "Install v{version}", + "settings.updates.up_to_date": "You're up to date", + "settings.updates.error": "Could not check for updates", + "settings.about.label": "About", + "settings.about.github": "GitHub", + "settings.about.report_bug": "Report a Bug", + "settings.ai.title": "Apple Intelligence", + "settings.ai.toggle_row_aria": "Toggle Apple Intelligence step descriptions", + "settings.ai.toggle_aria": "Apple Intelligence step descriptions", + "settings.ai.use_for_steps": "Use for step descriptions", + "settings.ai.eligibility.fallback": "Could not check system eligibility.", + "settings.ai.eligibility.ok": "No extra permissions. Requires Apple Intelligence enabled in System Settings. Runs on-device (offline). Step data stays on your Mac.", + "settings.ai.eligibility.with_reason": "{reason} No extra permissions. StepCast will use built-in descriptions. Step data stays on your Mac.", + "settings.ai.eligibility.checking": "Checking eligibility...", + "settings.ai.open_settings": "Open Apple Intelligence & Siri Settings", + "recorder.header.settings_title": "Settings", + "recorder.fallback_hint": "Tray icon hidden by another app — use Cmd+Shift+S to toggle", + "recorder.fallback_dismiss_title": "Dismiss", + "recorder.update.available": "v{version} available", + "recorder.update.install": "Install", + "recorder.update.updating": "Updating...", + "recorder.update.updated_to": "Updated to v{version}", + "recorder.permissions.missing": "Missing: {list}", + "recorder.permissions.screen_recording": "Screen Recording", + "recorder.permissions.accessibility": "Accessibility", + "recorder.permissions.ok": "OK", + "recorder.permissions.open_settings": "Open Settings", + "recorder.idle.start_recording": "Start Recording", + "recorder.idle.hint": "Click anywhere on your screen to capture steps", + "recorder.controls.pause": "Pause", + "recorder.controls.resume": "Resume", + "recorder.controls.stop": "Stop", + "recorder.steps.title": "Steps", + "recorder.steps.captured_count": "{count} captured", + "recorder.steps.empty": "Click anywhere to capture steps.", + "recorder.discard.dialog_title": "Discard Recording", + "recorder.discard.dialog_body": "Are you sure you want to discard {count} captured {count, plural, one {step} other {steps}}? This cannot be undone.", + "recorder.discard.dialog_ok": "Discard", + "recorder.discard.dialog_cancel": "Cancel", + "recorder.new.dialog_title": "New Recording", + "recorder.new.dialog_body": "Starting a new recording will discard {count} captured {count, plural, one {step} other {steps}}. Continue?", + "recorder.new.dialog_ok": "Discard & Record", + "recorder.new.button": "New", + "recorder.new.recording": "New Recording", + "recorder.new.discard_all": "Discard All", + "recorder.action.edit": "Edit", + "recorder.action.export": "Export", + "recorder.discard_title": "Discard recording", + "recorder.error.permissions_required": "Grant Screen Recording and Accessibility permissions to record.", + "export.title": "Export Guide", + "export.field.title": "Title", + "export.field.format": "Format", + "export.placeholder.title": "Guide title...", + "export.steps_count": "{count, plural, one {# step} other {# steps}}", + "export.format.html": "HTML", + "export.format.md": "MD", + "export.format.pdf": "PDF", + "welcome.title": "Welcome to StepCast", + "welcome.tip.menu_bar": "Click the menu bar icon to open this panel", + "welcome.tip.shortcut": "Press Cmd+Shift+S from anywhere", + "welcome.tip.right_click": "Right-click the icon for more options", + "welcome.tip.ai": "Optional: Apple Intelligence descriptions (on-device, macOS 26+, no step upload)", + "welcome.dismiss": "Got it", + "editor.title": "Edit Steps", + "editor.subtitle_steps": "{count} {count, plural, one {step} other {steps}}", + "editor.selection_count": "{count} selected", + "editor.enhance_selected": "Enhance", + "editor.delete_selected": "Delete", + "editor.deselect_all": "Deselect", + "editor.generating": "Generating…", + "editor.expand_all": "Expand All", + "editor.collapse_all": "Collapse All", + "editor.expand_all_title": "Expand all steps", + "editor.collapse_all_title": "Collapse all steps", + "editor.enhance_steps": "Enhance Steps", + "editor.enhance_disabled_title": "Enable Apple Intelligence descriptions in StepCast Settings", + "editor.enhance_generating_title": "AI generation in progress…", + "editor.enhance_default_title": "Generate concise descriptions for steps", + "editor.empty": "No steps recorded yet", + "editor.undo.step_deleted": "Step deleted", + "step.image_alt": "Step {num}", + "step.full_image_alt": "Step {num} full size", + "step.number": "Step {num}", + "step.action.clicked_in": "Clicked in", + "step.action.double_clicked_in": "Double-clicked in", + "step.action.right_clicked_in": "Right-clicked in", + "step.action.shortcut_in": "Used keyboard shortcut in", + "step.auth.default": "Authenticate with Touch ID or enter your password to continue.", + "step.drag_reorder_title": "Drag to reorder", + "step.delete.confirm_title": "Confirm delete", + "step.delete.remove_title": "Remove step", + "step.badge.double_click": "Double click", + "step.badge.right_click": "Right click", + "step.badge.shortcut": "Keyboard shortcut", + "step.select": "Select step", + "step.deselect": "Deselect step", + "step.expand": "Expand step", + "step.collapse": "Collapse step", + "step.description.placeholder": "Step description...", + "step.description.edit_title": "Edit description", + "step.note.placeholder": "Add a note...", + "step.note.button_default": "Add a note...", + "step.crop.adjust_title": "Adjust visible screenshot area", + "step.crop.adjusted_title": "Cropped — click to adjust", + "step.ai.generating_pill": "AI…", + "step.ai.generated_pill": "AI", + "step.ai.failed_pill": "AI!", + "step.ai.failed_default": "Apple Intelligence generation failed", + "step.ai.retry": "Retry", + "step.ai.retry_enabled_title": "Retry AI generation", + "step.ai.retry_disabled_title": "Enable Apple Intelligence in Settings", + "step.ai.button_disabled_title": "Enable Apple Intelligence descriptions in StepCast Settings", + "step.ai.button_generating_title": "Generating...", + "step.ai.button_default_title": "Generate with Apple Intelligence", + "step.ai.generating_title": "Generating with Apple Intelligence...", + "step.crop.modal_title": "Adjust Focus Crop", + "step.crop.close_title": "Close crop editor", + "step.crop.image_alt": "Adjust crop for step {num}", + "step.crop.view_full_size": "View full size", + "step.lightbox.close_title": "Close lightbox", + "test.hello": "Hello", + "test.only_en": "Only English", + "test.count": "{name} has {count, plural, one {# step} other {# steps}}" +} diff --git a/src/main.tsx b/src/main.tsx index 33b0e1c..be33f8d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,11 +2,14 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import { initTheme } from "./components/SettingsSheet"; +import { I18nProvider } from "./i18n"; initTheme(); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + , );