From b316b93d848ba8ef7ec42a7a3babda6b5e243520 Mon Sep 17 00:00:00 2001 From: 5Hyeons Date: Tue, 4 Aug 2026 01:43:11 -0700 Subject: [PATCH 01/19] fix(sidebar): keep the toolbar labels in the selected UI language (#11889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SidebarToolbar is a React.memo boundary whose props are shallow-equal on a language switch, so without its own useTranslation() subscription it kept the English copy it rendered at boot — the persisted locale is applied asynchronously, after the lazy catalog loads. Co-authored-by: 5Hyeons --- .../sidebar/SidebarToolbar.test.tsx | 27 ++++++++++++++++++- .../src/components/sidebar/SidebarToolbar.tsx | 7 +++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx index f3dde9621ea..1ef570524cb 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx @@ -4,6 +4,8 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store' +import { i18n } from '@/i18n/i18n' +import ko from '@/i18n/locales/ko.json' import SidebarToolbar from './SidebarToolbar' const mocks = vi.hoisted(() => ({ @@ -82,12 +84,15 @@ describe('SidebarToolbar moved workspace board hint', () => { } }) - afterEach(() => { + afterEach(async () => { roots.splice(0).forEach((root) => { act(() => root.unmount()) }) document.body.replaceChildren() vi.clearAllMocks() + // Why: the language-change case mutates the shared i18n instance; restore + // here so a failing assertion cannot leave later tests running in Korean. + await i18n.changeLanguage('en') }) it('does not show the moved hint to brand-new users after their first board click', async () => { @@ -131,6 +136,26 @@ describe('SidebarToolbar moved workspace board hint', () => { expect(window.localStorage.getItem('orca.workspaceBoardMovedHintSeen.v1')).toBe('true') }) + // Why: the toolbar is a React.memo boundary whose props never change on a + // language switch, so without its own useTranslation() subscription it keeps + // the English copy it rendered at boot (the persisted locale is applied + // asynchronously, after the lazy catalog loads). + it('relabels itself when the UI language changes after mount', async () => { + const { container } = await renderToolbar() + expect(container.querySelector('button[aria-label="Workspace board"]')).not.toBeNull() + + await act(async () => { + await i18n.changeLanguage('ko') + }) + + // Why: read the expected copy from the catalog instead of hardcoding it, so + // editing the Korean wording cannot fail this test as a bogus stale-render + // report. + const localized = ko.auto.components.sidebar.SidebarToolbar['49f62c5665'] + expect(localized).not.toBe('Workspace board') + expect(container.querySelector(`button[aria-label="${localized}"]`)).not.toBeNull() + }) + it('renders the profile switcher before settings in the footer controls', async () => { const { container } = await renderToolbar() const html = container.innerHTML diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index ff2e030c7a5..dcf57248839 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -1,5 +1,6 @@ import React from 'react' import { Kanban } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { ScrollToCurrentWorkspaceToolbarButton } from './ScrollToCurrentWorkspaceToolbarButton' @@ -23,6 +24,12 @@ const SidebarToolbar = React.memo(function SidebarToolbar({ workspaceBoardDragPreviewOpen = false, onWorkspaceBoardToggle }: SidebarToolbarProps) { + // Why: this memo boundary needs its own language subscription, while + // translate() preserves Orca's pseudo-localization behavior. Without it the + // toolbar (and the ScrollToCurrentWorkspaceToolbarButton it renders) keeps + // whatever language was active at boot — English, since the persisted locale + // is applied asynchronously after the lazy catalog loads. + useTranslation() const [workspaceBoardMovedHintOpen, setWorkspaceBoardMovedHintOpen] = React.useState(false) const movedHintEligibleRef = React.useRef(null) const persistedUIReady = useAppStore((state) => state.persistedUIReady) From a1fe9c5d92098a8129718528ce48369c0cde9adc Mon Sep 17 00:00:00 2001 From: Evgenii Date: Tue, 4 Aug 2026 01:43:26 -0700 Subject: [PATCH 02/19] fix(i18n): format the stats date with the selected UI language (#12105) formatTrackingSince() used the OS locale rather than the language chosen in Settings. getIntlLocale() resolves the active i18n language to a BCP-47 tag, mapping the synthetic plugin resource language that Intl rejects back to the pack's real locale. Co-authored-by: Evgenii --- .../src/components/stats/StatsPane.tsx | 4 +- src/renderer/src/i18n/i18n.ts | 18 +++++ src/renderer/src/i18n/intl-locale.test.ts | 79 +++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/i18n/intl-locale.test.ts diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx index 002a2dca4ae..4d62ad89bb5 100644 --- a/src/renderer/src/components/stats/StatsPane.tsx +++ b/src/renderer/src/components/stats/StatsPane.tsx @@ -15,7 +15,7 @@ import { DropdownMenuTrigger } from '../ui/dropdown-menu' import { AgentIcon } from '@/lib/agent-catalog' -import { translate } from '@/i18n/i18n' +import { getIntlLocale, translate } from '@/i18n/i18n' export { getStatsPaneSearchEntries } from './stats-search' function formatDuration(ms: number): string { @@ -44,7 +44,7 @@ function formatTrackingSince(timestamp: number | null): string { } const date = new Date(timestamp) return translate('auto.components.stats.StatsPane.trackingSince', 'Tracking since {{value0}}', { - value0: date.toLocaleDateString(undefined, { + value0: date.toLocaleDateString(getIntlLocale(), { month: 'short', day: 'numeric', year: 'numeric' diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index 3f35e2ec224..0d5cbad54cf 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -95,6 +95,24 @@ export async function setRendererUiLanguage(language: UiLanguage): Promise const registeredPluginLanguages = new Set() let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = [] +/** + * BCP-47 tag for `Intl` formatting. Plugin catalogs register under a synthetic + * `plugin` resource language that `Intl` rejects, so fall back to the tag + * the pack declares (for example `ru-RU`) and finally to the default locale. + */ +export function getIntlLocale(): string { + const active = i18n.language + const pack = pluginLanguagePacks.find((entry) => entry.resourceLanguage === active) + const candidate = pack?.locale ?? active + try { + // Why: an empty result means Intl has no data for the tag, so fall through + // to the default locale instead of letting Intl pick the runtime one. + return Intl.DateTimeFormat.supportedLocalesOf(candidate)[0] ?? DEFAULT_LOCALE + } catch { + return DEFAULT_LOCALE + } +} + export function resolveRendererResourceLanguage(language: string): string { return pluginLanguagePacks.find((pack) => pack.id === language)?.resourceLanguage ?? language } diff --git a/src/renderer/src/i18n/intl-locale.test.ts b/src/renderer/src/i18n/intl-locale.test.ts new file mode 100644 index 00000000000..69f0de08455 --- /dev/null +++ b/src/renderer/src/i18n/intl-locale.test.ts @@ -0,0 +1,79 @@ +/** + * `getIntlLocale()` exists because plugin catalogs register under a synthetic + * `plugin` resource language. Passing that straight to `Intl` throws, and + * passing `undefined` silently falls back to the OS locale rather than the + * language the user selected. + * + * The branch assertions stub `supportedLocalesOf` so they describe the helper's + * logic rather than whichever locales the runtime's ICU build happens to carry. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getIntlLocale, i18n, setRendererPluginLanguagePacks } from './i18n' +import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact' +import { DEFAULT_LOCALE } from './supported-languages' + +const PACK_ID = 'plugin:smwbev.russian/ru-RU' as const +const PACK_RESOURCE = pluginLanguageResourceId(PACK_ID) +const PACK = { + id: PACK_ID, + resourceLanguage: PACK_RESOURCE, + pluginKey: 'smwbev.russian', + locale: 'ru-RU', + catalog: {} +} + +type Packs = Parameters[0] + +async function activate(language: string, packs: Packs = []): Promise { + setRendererPluginLanguagePacks(packs) + await i18n.changeLanguage(language) +} + +/** Stubs ICU lookup so a locale counts as supported only when listed. */ +function withSupportedLocales(supported: readonly string[]): void { + vi.spyOn(Intl.DateTimeFormat, 'supportedLocalesOf').mockImplementation((requested) => { + const tags = Array.isArray(requested) ? requested : [requested as string] + return tags.filter((tag) => supported.includes(tag)) as string[] + }) +} + +afterEach(async () => { + vi.restoreAllMocks() + setRendererPluginLanguagePacks([]) + await i18n.changeLanguage(DEFAULT_LOCALE) +}) + +describe('getIntlLocale', () => { + it('passes a supported built-in locale straight through', async () => { + withSupportedLocales(['es']) + await activate('es') + expect(getIntlLocale()).toBe('es') + }) + + it('resolves a plugin resource language to the locale the pack declares', async () => { + withSupportedLocales(['ru-RU']) + await activate(PACK_RESOURCE, [PACK]) + // Without the pack lookup this would reach Intl as `plugin`. + expect(getIntlLocale()).toBe('ru-RU') + }) + + it('falls back to the default locale when ICU has no data for the tag', async () => { + withSupportedLocales([]) + await activate('es') + // Returning the tag here would let Intl silently format with the runtime locale. + expect(getIntlLocale()).toBe(DEFAULT_LOCALE) + }) + + it('falls back to the default locale when Intl rejects the tag', async () => { + vi.spyOn(Intl.DateTimeFormat, 'supportedLocalesOf').mockImplementation(() => { + throw new RangeError('invalid language tag') + }) + await activate(PACK_RESOURCE) + expect(getIntlLocale()).toBe(DEFAULT_LOCALE) + }) + + it('keeps the synthetic resource language unusable for Intl directly', () => { + // Unstubbed on purpose: this is a property of the tag, not of ICU data. + expect(() => Intl.DateTimeFormat.supportedLocalesOf(PACK_RESOURCE)).toThrow(RangeError) + }) +}) From 5aab004832cdd40a7f8fa706439c2318a689073f Mon Sep 17 00:00:00 2001 From: Evgenii Date: Tue, 4 Aug 2026 01:44:17 -0700 Subject: [PATCH 03/19] fix(plugins): let language packs translate plugin chrome (#12455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protectedTranslation refused every language-pack key under auto.components.settings.plugin*, which caught 104 keys that carry no trust meaning — section titles, empty states, Refresh, Add path. A 35-path exact allowlist opens those while consent, provenance, and every *Failed string stay protected; anything new stays protected until it is added deliberately. PluginsSettingsSection.experimental is held back from the contributed allowlist: the "Experimental" chip is a trust badge, which the module's own boundary comment places out of scope. Co-authored-by: Evgenii --- .../src/i18n/plugin-chrome-allowlist.test.ts | 56 +++++++++++ .../plugin-language-pack-artifact.test.ts | 60 ++++++++++++ .../plugins/plugin-language-pack-artifact.ts | 17 +++- .../plugins/plugin-translatable-chrome.ts | 92 +++++++++++++++++++ 4 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/i18n/plugin-chrome-allowlist.test.ts create mode 100644 src/shared/plugins/plugin-translatable-chrome.ts diff --git a/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts b/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts new file mode 100644 index 00000000000..19859369a78 --- /dev/null +++ b/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts @@ -0,0 +1,56 @@ +/** + * The plugin-chrome exemption is a list of exact catalog paths, so it can rot in + * two directions: a renamed key leaves a dead entry behind, and a key that moves + * into a component the security tests name would widen the exemption silently. + * These assertions catch both. + */ +import { describe, expect, it } from 'vitest' +import { translatablePluginChromePaths } from '../../../shared/plugins/plugin-translatable-chrome' +import en from './locales/en.json' + +// Kept in sync with the components plugin-language-pack-artifact.test.ts pins as +// security copy: nothing from them may appear in the chrome exemption. +const SECURITY_COPY_COMPONENTS = [ + 'PluginConsentDialog', + 'PluginConsentProvenance', + 'pluginError', + 'PluginKeybindingConsentPreview', + 'PluginMarketplaceListingRow', + 'PluginMarketplacePreviewDialog', + 'PluginMarketplaceSourceDialog', + 'PluginVmRecipeConsentPreview' +] + +function lookup(key: string): string | undefined { + const value = key + .split('.') + .reduce( + (node, part) => + node && typeof node === 'object' ? (node as Record)[part] : undefined, + en as unknown + ) + return typeof value === 'string' ? value : undefined +} + +describe('translatable plugin chrome', () => { + it.each(translatablePluginChromePaths())('%s still exists in the English catalog', (path) => { + expect(lookup(path)).toBeTypeOf('string') + }) + + it('exempts nothing from a component the security tests pin', () => { + const leaked = translatablePluginChromePaths().filter((path) => + SECURITY_COPY_COMPONENTS.some((component) => + path.startsWith(`auto.components.settings.${component}.`) + ) + ) + + expect(leaked).toEqual([]) + }) + + // Why: `*Failed` copy reports what happened to a trust decision (a reviewed + // source that changed, a rollback target that is gone), so the boundary is the + // whole family rather than a per-message judgement. + it('exempts no failure copy', () => { + expect(translatablePluginChromePaths().filter((path) => path.endsWith('Failed'))).toEqual([]) + }) +}) diff --git a/src/shared/plugins/plugin-language-pack-artifact.test.ts b/src/shared/plugins/plugin-language-pack-artifact.test.ts index c3f9980e7e6..e684bb50095 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.test.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.test.ts @@ -57,6 +57,66 @@ describe('plugin language-pack artifacts', () => { ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) }) + it.each([ + ['PluginsSettingsSection', 'title', 'Плагины'], + ['PluginMarketplaceBrowser', 'refresh', 'Обновить'], + ['PluginDevelopmentSection', 'title', 'Разработка'] + ])('lets a language pack translate %s.%s, which asserts nothing', (component, key, value) => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { components: { settings: { [component]: { [key]: value } } } } + }) + ).ok + ).toBe(true) + }) + + // Why: the chrome exemption must not leak into the copy that carries a claim + // about what plugins may do — that copy is the whole reason the prefix is broad. + it.each([ + ['description', 'Plugins are sandboxed and cannot read your files.'], + ['systemDescription', 'Every plugin here has been reviewed by Orca.'], + ['featureOff', 'Installed plugins keep running while the system is off.'] + ])('still refuses PluginsSettingsSection.%s', (key, value) => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { components: { settings: { PluginsSettingsSection: { [key]: value } } } } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + + it('still refuses the development-plugin permission promise', () => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { + components: { + settings: { + PluginDevelopmentSection: { help: 'Dev plugins skip permission review.' } + } + } + } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + + it('still refuses install failure copy that reports a trust event', () => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { + components: { + settings: { PluginMarketplaceBrowser: { installFailed: 'Installed successfully.' } } + } + } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + it('forges no trust badge: the community→Official swap is refused', () => { expect( parsePluginLanguagePackArtifact( diff --git a/src/shared/plugins/plugin-language-pack-artifact.ts b/src/shared/plugins/plugin-language-pack-artifact.ts index 0e4dd218d95..917adeef69e 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.ts @@ -1,3 +1,8 @@ +import { + translatablePluginChrome, + translatablePluginChromeContainer +} from './plugin-translatable-chrome' + export const PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES = 20_000 export const PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH = 16 @@ -5,6 +10,8 @@ const DANGEROUS_CATALOG_KEYS = new Set(['__proto__', 'prototype', 'constructor'] // Why: every plugin-facing security surface lives under this namespace, so // protecting the whole subtree keeps a new dialog from silently becoming // plugin-writable the way PluginConsentProvenance did when it was extracted. +// The subtree also holds copy with no security meaning; those exact paths are +// listed in plugin-translatable-chrome.ts and stay translatable. const PROTECTED_TRANSLATION_ROOT = 'auto.components.settings.' const PROTECTED_TRANSLATION_MODULE = /^plugin/i @@ -45,6 +52,9 @@ function protectedTranslation(path: string): boolean { if (!path.startsWith(PROTECTED_TRANSLATION_ROOT)) { return false } + if (translatablePluginChrome(path)) { + return false + } return PROTECTED_TRANSLATION_MODULE.test(path.slice(PROTECTED_TRANSLATION_ROOT.length)) } @@ -88,7 +98,12 @@ export function parsePluginLanguagePackArtifact(raw: string): PluginLanguagePack return { ok: false, error: `catalog key ${key || '(empty)'} is not safe` } } const path = frame.path ? `${frame.path}.${key}` : key - if (protectedTranslation(path)) { + // A protected container stays walkable only when exempt chrome sits below + // it; every leaf inside is still matched against its own full path. + if ( + protectedTranslation(path) && + !(isCatalogObject(value) && translatablePluginChromeContainer(path)) + ) { return { ok: false, error: `catalog cannot replace protected security copy at ${path}` } } if (typeof value === 'string') { diff --git a/src/shared/plugins/plugin-translatable-chrome.ts b/src/shared/plugins/plugin-translatable-chrome.ts new file mode 100644 index 00000000000..7a2e4c0fa2a --- /dev/null +++ b/src/shared/plugins/plugin-translatable-chrome.ts @@ -0,0 +1,92 @@ +// Why: the protected-translation prefix is deliberately broad so a consent +// surface added tomorrow is covered the day it lands — the lesson from +// PluginConsentProvenance. That breadth also catches copy that carries no +// security meaning at all: section titles, empty states, search affordances, +// and the local development form. A language pack that cannot translate +// "Refresh" leaves the plugins pane half-translated in every locale. +// +// This list is the narrow exception, and it is a list of exact paths rather +// than a pattern on purpose: anything new stays protected until someone +// deliberately adds it here, so the fail-safe survives. +// +// A path belongs here only if rewriting it cannot mislead the person deciding +// whether to trust a plugin. That rules out, and this list therefore omits: +// +// - trust badges and safety status — `PluginMarketplaceListingRow.official`, +// `.blocked`, `PluginSettingsRow.blocked`; +// - promises about what plugins may do — `PluginsSettingsSection.description` +// ("Plugins run on this computer"), `.systemDescription` ("Nothing runs +// until you review and enable it"), `.featureOff` ("stays disabled"), and +// `PluginDevelopmentSection.help` ("Dev plugins still require permission +// review"). Swapping any of these for reassuring text is the attack; +// - failure copy that reports a trust event — `installFailed` ("The reviewed +// source may have changed"). Sibling `*Failed` strings stay protected too, +// so the boundary is a rule rather than a judgement call per message; +// - destructive confirmations — `PluginRemoveDialog`, `PluginRollbackDialog`; +// - every dialog the existing tests already name as security copy. +const TRANSLATABLE_PLUGIN_CHROME = new Set([ + // Plugins pane frame: headings and list states, no claims about behavior. + 'auto.components.settings.PluginsSettingsSection.title', + 'auto.components.settings.PluginsSettingsSection.systemLabel', + 'auto.components.settings.PluginsSettingsSection.install', + 'auto.components.settings.PluginsSettingsSection.loading', + 'auto.components.settings.PluginsSettingsSection.empty', + 'auto.components.settings.PluginsSettingsSection.emptyTitle', + 'auto.components.settings.PluginsSettingsSection.noInstalledResults', + 'auto.components.settings.PluginsSettingsSection.noInstalledResultsTitle', + // Marketplace browser chrome: refresh, search, and empty states. + 'auto.components.settings.PluginMarketplaceBrowser.manageSources', + 'auto.components.settings.PluginMarketplaceBrowser.addSource', + 'auto.components.settings.PluginMarketplaceBrowser.refresh', + 'auto.components.settings.PluginMarketplaceBrowser.refreshing', + 'auto.components.settings.PluginMarketplaceBrowser.loading', + 'auto.components.settings.PluginMarketplaceBrowser.tryAgain', + 'auto.components.settings.PluginMarketplaceBrowser.clearSearch', + 'auto.components.settings.PluginMarketplaceBrowser.empty', + 'auto.components.settings.PluginMarketplaceBrowser.emptyTitle', + 'auto.components.settings.PluginMarketplaceBrowser.noInstalled', + 'auto.components.settings.PluginMarketplaceBrowser.noInstalledTitle', + 'auto.components.settings.PluginMarketplaceBrowser.noResults', + 'auto.components.settings.PluginMarketplaceBrowser.noResultsTitle', + 'auto.components.settings.PluginMarketplaceBrowser.noSourcesTitle', + // Local development form: field labels and validation, desktop-only paths. + 'auto.components.settings.PluginDevelopmentSection.title', + 'auto.components.settings.PluginDevelopmentSection.add', + 'auto.components.settings.PluginDevelopmentSection.remove', + 'auto.components.settings.PluginDevelopmentSection.pathLabel', + 'auto.components.settings.PluginDevelopmentSection.pathRequired', + 'auto.components.settings.PluginDevelopmentSection.placeholder', + // Settings-search index entries: they route to a pane, they do not assert. + 'auto.components.settings.plugins.search.title', + 'auto.components.settings.plugins.search.description', + 'auto.components.settings.plugins.search.install', + 'auto.components.settings.plugins.search.permissions', + 'auto.components.settings.plugins.search.logs', + 'auto.components.settings.plugins.search.development' +]) + +/** True when a protected-prefix path is plugin chrome a language pack may translate. */ +export function translatablePluginChrome(path: string): boolean { + return TRANSLATABLE_PLUGIN_CHROME.has(path) +} + +/** + * True when a protected-prefix container holds exempt chrome somewhere below it. + * The walk rejects a protected path as soon as it sees it, so a container has to + * stay walkable for its exempt leaves to be reachable — every leaf inside is + * still checked against its own full path. + */ +export function translatablePluginChromeContainer(path: string): boolean { + const prefix = `${path}.` + for (const exempt of TRANSLATABLE_PLUGIN_CHROME) { + if (exempt.startsWith(prefix)) { + return true + } + } + return false +} + +/** The exempt paths, for tests that check the list against the English catalog. */ +export function translatablePluginChromePaths(): string[] { + return [...TRANSLATABLE_PLUGIN_CHROME] +} From 853c2043b96c8d27c082939549adafa14f3035de Mon Sep 17 00:00:00 2001 From: Evgenii Date: Tue, 4 Aug 2026 01:44:30 -0700 Subject: [PATCH 04/19] fix(i18n): localize the integration card status pills (#12208) Every card under Settings -> Integrations built its status pill from bare literals inside the JSX call, so the pill stayed English beside a translated description. audit-localization-coverage.mjs inspects JSX attributes and object properties, not conditional expressions handed to a prop, so the gate stayed green while the strings shipped untranslated. The three token-configured cards share tokenProviderStatusLabel(); Gitea passes optional: true because it works read-only without a token, which is why its unconfigured state reads Optional setup rather than Not configured. Co-authored-by: Evgenii --- .../settings/jira-integration-card.tsx | 9 +++- .../task-tracker-integration-cards.tsx | 12 ++++- ...token-source-control-integration-cards.tsx | 44 ++++++------------- .../settings/token-source-control-status.ts | 39 ++++++++++++++++ ...tegration-card-status-localization.test.ts | 44 +++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 16 +++++-- 6 files changed, 128 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/components/settings/token-source-control-status.ts create mode 100644 src/renderer/src/i18n/integration-card-status-localization.test.ts diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index e1889c2f073..e9f510628c6 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -99,7 +99,14 @@ export function JiraIntegrationCard(): React.JSX.Element { } checking={checking} statusTone={connected ? 'connected' : 'attention'} - statusLabel={connected ? 'Connected' : 'Not connected'} + statusLabel={ + connected + ? translate('auto.components.settings.jira.integration.card.statusConnected', 'Connected') + : translate( + 'auto.components.settings.jira.integration.card.statusNotConnected', + 'Not connected' + ) + } actions={ !checking ? (