From dffc2928885d765c584b1b925d25efd210e5288d Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:57:12 +0100 Subject: [PATCH] I18n on portal (#6761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview Internationalizes the **developer portal**, which previously had **zero i18n** — every string was hardcoded across ~118 components. Rather than stand up a parallel system, this shares the **editor's** existing i18n setup (same TOML locale format, same Crowdin pipeline), then converts every portal surface to `react-i18next` and adds a CI guard so coverage can't regress. ## What's included ### 🔗 Shared i18n core (`@shared/i18n`) - Extracts the editor's `TomlBackend` (HTTP loader for `public/locales/{lng}/translation.toml`) and language metadata/helpers (the 42-language list, RTL set, `LanguageSource` priority, code normalizers) into `frontend/shared/i18n/`. - The **editor** now imports and re-exports these from `@shared/i18n` — its 20+ consumers are unchanged. Its local `tomlBackend.ts` is deleted. - The **portal** builds its own i18next instance from the shared core, with **en-US as the source of truth** and the same `/locales/{lng}/translation.toml` layout. ### 🌍 Full portal coverage - Every view and component converted to `t()` — all feature areas (home, pipelines, sources, infrastructure, usage, documents, agent-builder, editor-admin, policies, users, docs, catalogue, components view) plus app shell, nav, modals, and the home/domain widgets. - **1108 keys across ~30 namespaces** in `portal/public/locales/en-US/translation.toml`, grouped by feature; shared strings under `[common]`. Plurals use i18next count forms; dynamic labels (nav, settings sections, status badges) use template keys against populated tables. - Data-driven strings (values from `@portal/api/*` mocks, enum/id values, code samples) are intentionally left untranslated — they're data, not UI chrome. ### ✅ CI coverage guard - `portal/scripts/check-i18n.mjs` fails if any static `t("key")` in portal source is missing from the en-US locale. Wired into `frontend:check` and `frontend:check:all`, so missed keys break CI. This mirrors the editor's `missingTranslations` test for the portal, which has no vitest harness of its own. ## Testing - `task frontend:check:all` passes locally (typecheck all variants, lint, format, **portal i18n guard**, builds, tests, storybook). - Every static `t()` key verified to resolve in the locale (1108 keys / 186 source files); all dynamic key prefixes map to populated tables. - Runtime sweep of all 12 portal routes shows **no unresolved keys** on screen; nav labels, plurals, and array-backed copy all render real text. ## Follow-ups (not in this PR) - **Crowdin** — register `frontend/portal/public/locales/` as a source so portal strings flow through the same translation pipeline as the editor (an ops step on the Crowdin side; there's no Crowdin config in the repo). - Only `en-US` is populated; other languages will arrive via the pipeline. --- frontend/editor/src/core/i18n.ts | 114 +- frontend/editor/src/core/i18n/config.ts | 2 +- .../core/tests/missingTranslations.test.ts | 262 +-- .../src/core/tests/unusedTranslations.test.ts | 252 +-- frontend/eslint.config.mjs | 1 + frontend/portal/main.tsx | 8 +- .../public/locales/en-US/translation.toml | 1739 +++++++++++++++++ .../portal/src/components/AssistantButton.tsx | 6 +- .../portal/src/components/AssistantPanel.tsx | 31 +- frontend/portal/src/components/Header.tsx | 23 +- .../portal/src/components/MocksToggle.tsx | 10 +- .../src/components/NotificationsDropdown.tsx | 28 +- .../src/components/PipelineForkWizard.tsx | 21 +- .../portal/src/components/PolicySummary.tsx | 46 +- .../portal/src/components/PopularUseCases.tsx | 68 +- .../src/components/ProcessingStatusStrip.tsx | 12 +- .../portal/src/components/RecentActivity.tsx | 12 +- .../portal/src/components/SearchModal.tsx | 16 +- .../portal/src/components/SettingsModal.tsx | 304 +-- frontend/portal/src/components/Sidebar.tsx | 58 +- .../portal/src/components/SingleOpRunner.tsx | 62 +- .../portal/src/components/UsageAreaChart.tsx | 26 +- .../portal/src/components/WelcomeCarousel.tsx | 70 +- .../agent-builder/AgentBuilderPanel.tsx | 20 +- .../agent-builder/AgentKpiStrip.tsx | 37 +- .../agent-builder/AgentSelector.tsx | 7 +- .../agent-builder/BootstrapDialog.tsx | 16 +- .../components/agent-builder/EvalsPanel.tsx | 77 +- .../agent-builder/ScenariosPanel.tsx | 22 +- .../components/agent-builder/ToolsPanel.tsx | 18 +- .../agent-builder/VersionsPanel.tsx | 11 +- .../components/catalogue/ComponentCard.tsx | 9 +- .../catalogue/ComponentDetailModal.tsx | 90 +- .../catalogue/ComponentPropsTable.tsx | 18 +- .../catalogue/ComponentsSummaryStrip.tsx | 16 +- .../components/docs/AuthenticationSection.tsx | 14 +- .../src/components/docs/ComponentsSection.tsx | 10 +- .../portal/src/components/docs/DocsNav.tsx | 4 +- .../docs/EndpointReferenceSection.tsx | 20 +- .../src/components/docs/ErrorsSection.tsx | 10 +- .../components/docs/GettingStartedSection.tsx | 47 +- .../src/components/docs/PlaybooksSection.tsx | 10 +- .../src/components/docs/RateLimitsSection.tsx | 22 +- .../src/components/docs/SdksSection.tsx | 16 +- .../src/components/docs/SkillsSection.tsx | 8 +- .../src/components/docs/WebhooksSection.tsx | 17 +- .../components/documents/DocumentAudit.tsx | 6 +- .../components/documents/DocumentDrawer.tsx | 18 +- .../documents/DocumentExtractions.tsx | 64 +- .../components/documents/DocumentOverview.tsx | 23 +- .../documents/DocumentsSummaryStrip.tsx | 10 +- .../components/documents/ElevationBanner.tsx | 18 +- .../src/components/documents/ReviewQueue.tsx | 20 +- .../components/documents/ReviewQueueTable.tsx | 28 +- .../editor-admin/CredentialRotationCard.tsx | 25 +- .../editor-admin/DeploymentTargets.tsx | 71 +- .../editor-admin/InstanceHealthTable.tsx | 111 +- .../editor-admin/OfflineActivationCard.tsx | 25 +- .../components/editor-admin/PairingPanel.tsx | 12 +- .../components/infrastructure/ApiKeyCard.tsx | 22 +- .../components/infrastructure/ApiKeysTab.tsx | 12 +- .../components/infrastructure/AuditTab.tsx | 133 +- .../infrastructure/CreateKeyModal.tsx | 35 +- .../infrastructure/DeploymentsTab.tsx | 284 +-- .../components/infrastructure/ModelsTab.tsx | 211 +- .../components/infrastructure/SecurityTab.tsx | 178 +- .../components/infrastructure/StorageTab.tsx | 98 +- .../pipelines/DeployedPipelinesTable.tsx | 22 +- .../src/components/pipelines/PipelineCard.tsx | 41 +- .../components/pipelines/PipelineComposer.tsx | 75 +- .../components/pipelines/PipelineDetail.tsx | 75 +- .../pipelines/PromotedPipelines.tsx | 27 +- .../components/policies/CatalogueSummary.tsx | 18 +- .../policies/PolicyCategoryCard.tsx | 26 +- .../components/policies/PolicyDetailPanel.tsx | 60 +- .../components/policies/PolicySetupWizard.tsx | 114 +- .../src/components/sources/AgentPanel.tsx | 33 +- .../src/components/sources/ApiClientPanel.tsx | 42 +- .../src/components/sources/ConnectWizard.tsx | 60 +- .../src/components/sources/KpiStrip.tsx | 18 +- .../components/sources/SourceDetailCard.tsx | 9 +- .../src/components/sources/SourcesTable.tsx | 16 +- .../src/components/sources/WebhookPanel.tsx | 19 +- .../src/components/usage/AvailablePlans.tsx | 10 +- .../components/usage/BillingHistoryTable.tsx | 40 +- .../src/components/usage/BillingKpiStrip.tsx | 41 +- .../src/components/usage/CurrentPlanCard.tsx | 71 +- .../portal/src/components/usage/PlanCard.tsx | 10 +- .../src/components/usage/SpendCapControl.tsx | 41 +- .../src/components/usage/UpgradeModal.tsx | 68 +- .../src/components/usage/UsageChart.tsx | 6 +- .../src/components/users/AccessControls.tsx | 78 +- .../components/users/InviteMemberModal.tsx | 20 +- .../src/components/users/MembersTable.tsx | 22 +- .../portal/src/components/users/RolesGrid.tsx | 11 +- .../components/users/UsersSummaryStrip.tsx | 28 +- frontend/portal/src/i18n/config.ts | 54 + frontend/portal/src/views/AgentBuilder.tsx | 18 +- frontend/portal/src/views/Components.tsx | 18 +- frontend/portal/src/views/DeveloperDocs.tsx | 6 +- frontend/portal/src/views/Documents.tsx | 8 +- frontend/portal/src/views/EditorAdmin.tsx | 22 +- frontend/portal/src/views/Home.tsx | 129 +- frontend/portal/src/views/Infrastructure.tsx | 33 +- frontend/portal/src/views/Pipelines.tsx | 51 +- frontend/portal/src/views/Policies.tsx | 10 +- frontend/portal/src/views/Sources.tsx | 21 +- frontend/portal/src/views/Usage.tsx | 8 +- frontend/portal/src/views/Users.tsx | 19 +- frontend/shared/i18n/languages.ts | 97 + .../src/core => shared}/i18n/tomlBackend.ts | 5 + frontend/shared/i18n/translationAudit.ts | 355 ++++ 112 files changed, 4796 insertions(+), 2253 deletions(-) create mode 100644 frontend/portal/public/locales/en-US/translation.toml create mode 100644 frontend/portal/src/i18n/config.ts create mode 100644 frontend/shared/i18n/languages.ts rename frontend/{editor/src/core => shared}/i18n/tomlBackend.ts (86%) create mode 100644 frontend/shared/i18n/translationAudit.ts diff --git a/frontend/editor/src/core/i18n.ts b/frontend/editor/src/core/i18n.ts index cf0fcf7b5f..82b860b467 100644 --- a/frontend/editor/src/core/i18n.ts +++ b/frontend/editor/src/core/i18n.ts @@ -1,73 +1,29 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; -import TomlBackend from "@app/i18n/tomlBackend"; +import TomlBackend from "@shared/i18n/tomlBackend"; +import { + supportedLanguages, + rtlLanguages, + I18N_STORAGE_KEYS, + LanguageSource, + normalizeLanguageCode, + toUnderscoreFormat, + toUnderscoreLanguages, +} from "@shared/i18n/languages"; -// Define supported languages (based on your existing translations) -export const supportedLanguages = { - "en-US": "English (US)", - "en-GB": "English (UK)", - "ar-AR": "العربية", - "az-AZ": "Azərbaycan Dili", - "bg-BG": "Български", - "ca-CA": "Català", - "cs-CZ": "Česky", - "da-DK": "Dansk", - "de-DE": "Deutsch", - "el-GR": "Ελληνικά", - "es-ES": "Español", - "eu-ES": "Euskara", - "fa-IR": "فارسی", - "fr-FR": "Français", - "ga-IE": "Gaeilge", - "hi-IN": "हिंदी", - "hr-HR": "Hrvatski", - "hu-HU": "Magyar", - "id-ID": "Bahasa Indonesia", - "it-IT": "Italiano", - "ja-JP": "日本語", - "ko-KR": "한국어", - "ml-ML": "മലയാളം", - "nl-NL": "Nederlands", - "no-NB": "Norsk", - "pl-PL": "Polski", - "pt-BR": "Português (Brasil)", - "pt-PT": "Português", - "ro-RO": "Română", - "ru-RU": "Русский", - "sk-SK": "Slovensky", - "sl-SI": "Slovenščina", - "sr-LATN-RS": "Srpski", - "sv-SE": "Svenska", - "th-TH": "ไทย", - "tr-TR": "Türkçe", - "uk-UA": "Українська", - "vi-VN": "Tiếng Việt", - "zh-BO": "བོད་ཡིག", - "zh-CN": "简体中文", - "zh-TW": "繁體中文", +// Language metadata and code helpers are shared with the portal via +// @shared/i18n. Re-export them so existing `@app/i18n` consumers are unchanged. +export { + supportedLanguages, + rtlLanguages, + I18N_STORAGE_KEYS, + LanguageSource, + normalizeLanguageCode, + toUnderscoreFormat, + toUnderscoreLanguages, }; -// RTL languages (based on your existing language.direction property) -export const rtlLanguages = ["ar-AR", "fa-IR"]; - -// LocalStorage keys for i18next -export const I18N_STORAGE_KEYS = { - LANGUAGE: "i18nextLng", - LANGUAGE_SOURCE: "i18nextLng-source", -} as const; - -/** - * Language selection priority levels - * Higher number = higher priority (cannot be overridden by lower priority) - */ -export enum LanguageSource { - Fallback = 0, - Browser = 1, - ServerDefault = 2, - User = 3, -} - i18n .use(TomlBackend) .use(LanguageDetector) @@ -139,36 +95,6 @@ i18n.on("initialized", () => { } }); -export function normalizeLanguageCode(languageCode: string): string { - // Replace underscores with hyphens to align with i18next/translation file naming - const hyphenated = languageCode.replace(/_/g, "-"); - const [base, ...rest] = hyphenated.split("-"); - - if (rest.length === 0) { - return base.toLowerCase(); - } - - const normalizedParts = rest.map((part) => - part.length <= 3 ? part.toUpperCase() : part, - ); - return [base.toLowerCase(), ...normalizedParts].join("-"); -} - -/** - * Convert language codes to underscore format (e.g., en-US → en_US) - * Used for backend API communication which expects underscore format - */ -export function toUnderscoreFormat(languageCode: string): string { - return languageCode.replace(/-/g, "_"); -} - -/** - * Convert array of language codes to underscore format - */ -export function toUnderscoreLanguages(languages: string[]): string[] { - return languages.map(toUnderscoreFormat); -} - /** * Get the current language source priority */ diff --git a/frontend/editor/src/core/i18n/config.ts b/frontend/editor/src/core/i18n/config.ts index 02b79f06b1..47e028c1f4 100644 --- a/frontend/editor/src/core/i18n/config.ts +++ b/frontend/editor/src/core/i18n/config.ts @@ -1,6 +1,6 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; -import TomlBackend from "@app/i18n/tomlBackend"; +import TomlBackend from "@shared/i18n/tomlBackend"; i18n .use(TomlBackend) diff --git a/frontend/editor/src/core/tests/missingTranslations.test.ts b/frontend/editor/src/core/tests/missingTranslations.test.ts index 6448d3cb63..f98022559b 100644 --- a/frontend/editor/src/core/tests/missingTranslations.test.ts +++ b/frontend/editor/src/core/tests/missingTranslations.test.ts @@ -1,241 +1,53 @@ import fs from "fs"; import path from "path"; -import ts from "typescript"; import { describe, expect, test } from "vitest"; -import { parse } from "smol-toml"; +import { + I18N_PROJECTS, + REPO_ROOT, + findMissingKeys, +} from "@shared/i18n/translationAudit"; -const REPO_ROOT = path.join(__dirname, "../../../.."); -const SRC_ROOT = path.join(__dirname, "../.."); -const EN_US_FILE = path.join( - __dirname, - "../../../public/locales/en-US/translation.toml", -); +// One suite per frontend app (editor + portal). The scan logic lives in +// @shared/i18n/translationAudit so both apps share one implementation. +describe.each(I18N_PROJECTS)( + "Missing translation coverage — $name", + (project) => { + test( + "fails if any en-US key used in source is missing from the locale", + { timeout: 10000 }, + () => { + expect(fs.existsSync(project.localeFile)).toBe(true); -const IGNORED_DIRS = new Set(["tests", "__mocks__"]); -const IGNORED_FILE_PATTERNS = [ - /\.d\.ts$/, - /\.test\./, - /\.spec\./, - /\.stories\./, -]; -const IGNORED_KEYS = new Set([ - // If the script has found a false-positive that shouldn't be in the translations, include it here -]); -const LIKELY_TRANSLATION_USAGE_RE = /(?:^|[^\w$])t\s*\(|\.t\s*\(|\bi18nKey\b/; -const PLURAL_SUFFIX_RE = /_(zero|one|two|few|many|other)$/; + const { missing, usedCount } = findMissingKeys(project); + expect(usedCount).toBeGreaterThan(project.minUsedKeys ?? 1); // scan sanity -type FoundKey = { - key: string; - fallback: string; - file: string; - line: number; - column: number; -}; - -const flattenKeys = ( - node: unknown, - prefix = "", - acc = new Set(), -): Set => { - if (!node || typeof node !== "object" || Array.isArray(node)) { - if (prefix) { - acc.add(prefix); - } - return acc; - } - - for (const [childKey, value] of Object.entries( - node as Record, - )) { - const next = prefix ? `${prefix}.${childKey}` : childKey; - flattenKeys(value, next, acc); - } - - return acc; -}; - -const hasPluralCoverage = (key: string, availableKeys: Set): boolean => - [...availableKeys].some( - (availableKey) => - availableKey.startsWith(`${key}_`) && PLURAL_SUFFIX_RE.test(availableKey), - ); - -const listSourceFiles = (): string[] => { - const files = ts.sys.readDirectory( - SRC_ROOT, - [".ts", ".tsx", ".js", ".jsx"], - undefined, - ["**/*"], - ); - - return files - .filter( - (file) => - !file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)), - ) - .filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file))); -}; - -const getScriptKind = (file: string): ts.ScriptKind => { - if (file.endsWith(".tsx")) { - return ts.ScriptKind.TSX; - } - - if (file.endsWith(".ts")) { - return ts.ScriptKind.TS; - } - - if (file.endsWith(".jsx")) { - return ts.ScriptKind.JSX; - } - - return ts.ScriptKind.JS; -}; - -/** - * Find all of the static first keys for translation functions that we can. - * Ignores dynamic strings because we can't know what the actual translation key will be. - */ -const extractKeys = (file: string): FoundKey[] => { - const code = fs.readFileSync(file, "utf8"); - if (!LIKELY_TRANSLATION_USAGE_RE.test(code)) { - return []; - } - - const sourceFile = ts.createSourceFile( - file, - code, - ts.ScriptTarget.Latest, - true, - getScriptKind(file), - ); - - const found: FoundKey[] = []; - - const record = (node: ts.Node, key: string, fallback: string = "") => { - const { line, character } = sourceFile.getLineAndCharacterOfPosition( - node.getStart(), - ); - found.push({ key, fallback, file, line: line + 1, column: character + 1 }); - }; - - const visit = (node: ts.Node) => { - if (ts.isCallExpression(node)) { - const callee = node.expression; - const arg0 = node.arguments.at(0); - const arg1 = node.arguments.at(1); - - const isT = - (ts.isIdentifier(callee) && callee.text === "t") || - (ts.isPropertyAccessExpression(callee) && callee.name.text === "t"); - - if ( - isT && - arg0 && - (ts.isStringLiteral(arg0) || ts.isNoSubstitutionTemplateLiteral(arg0)) - ) { - let arg1Text: string = ""; - if ( - arg1 && - (ts.isStringLiteral(arg1) || ts.isNoSubstitutionTemplateLiteral(arg1)) - ) { - arg1Text = arg1.text; - } - record(arg0, arg0.text, arg1Text); - } - } - - if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { - for (const attr of node.attributes.properties) { - if ( - !ts.isJsxAttribute(attr) || - attr.name.getText(sourceFile) !== "i18nKey" || - !attr.initializer - ) { - continue; - } - - const init = attr.initializer; - - if (ts.isStringLiteral(init)) { - record(init, init.text); - continue; - } - - if ( - ts.isJsxExpression(init) && - init.expression && - ts.isStringLiteral(init.expression) - ) { - record(init.expression, init.expression.text); - } - } - } - - ts.forEachChild(node, visit); - }; - - ts.forEachChild(sourceFile, visit); - return found; -}; - -describe("Missing translation coverage", () => { - test( - "fails if any en-US translation key used in source is missing", - { timeout: 10000 }, - () => { - expect(fs.existsSync(EN_US_FILE)).toBe(true); - - const localeContent = fs.readFileSync(EN_US_FILE, "utf8"); - const enUs = parse(localeContent); - const availableKeys = flattenKeys(enUs); - - const usedKeys = listSourceFiles() - .flatMap(extractKeys) - .filter(({ key }) => !IGNORED_KEYS.has(key)); - expect(usedKeys.length).toBeGreaterThan(100); // Sanity check - - const missingKeys = usedKeys.filter( - ({ key }) => - !availableKeys.has(key) && !hasPluralCoverage(key, availableKeys), - ); - - const annotations = missingKeys.map( - ({ key, fallback, file, line, column }) => { - const workspaceRelativeRaw = path.relative(REPO_ROOT, file); - const workspaceRelativeFile = workspaceRelativeRaw.replace( - /\\/g, - "/", - ); - - return { + const annotations = missing.map( + ({ key, fallback, file, line, column }) => ({ key, fallback, - file: workspaceRelativeFile, + file: path.relative(REPO_ROOT, file).replace(/\\/g, "/"), line, column, - }; - }, - ); - - // Output errors in GitHub Annotations format so they appear tagged in the code in CI - for (const { key, fallback, file, line, column } of annotations) { - process.stderr.write( - `::error file=${file},line=${line},col=${column}::Missing en-US translation for ${key} (${fallback})\n`, + }), ); - } - const neatened = annotations.map( - ({ key, fallback, file, line, column }) => { - return { + // GitHub Annotations format so misses show up tagged on the code in CI. + for (const { key, fallback, file, line, column } of annotations) { + process.stderr.write( + `::error file=${file},line=${line},col=${column}::Missing en-US translation for ${key} (${fallback})\n`, + ); + } + + const located = annotations.map( + ({ key, fallback, file, line, column }) => ({ key, fallback, location: `${file}:${line}:${column}`, - }; - }, - ); + }), + ); - expect(neatened).toEqual([]); - }, - ); -}); + expect(located).toEqual([]); + }, + ); + }, +); diff --git a/frontend/editor/src/core/tests/unusedTranslations.test.ts b/frontend/editor/src/core/tests/unusedTranslations.test.ts index 2e3d408ce0..1193c737bf 100644 --- a/frontend/editor/src/core/tests/unusedTranslations.test.ts +++ b/frontend/editor/src/core/tests/unusedTranslations.test.ts @@ -1,224 +1,46 @@ import fs from "fs"; import path from "path"; -import ts from "typescript"; import { describe, expect, test } from "vitest"; -import { parse } from "smol-toml"; +import { + I18N_PROJECTS, + REPO_ROOT, + findUnusedKeys, +} from "@shared/i18n/translationAudit"; -const REPO_ROOT = path.join(__dirname, "../../../.."); -const SRC_ROOT = path.join(__dirname, "../.."); -const EN_US_FILE = path.join( - __dirname, - "../../../public/locales/en-US/translation.toml", -); +// One suite per frontend app (editor + portal). The scan logic lives in +// @shared/i18n/translationAudit so both apps share one implementation; each +// project carries its own ignoredKeyPatterns for runtime-assembled keys. +describe.each(I18N_PROJECTS)( + "Unused translation coverage — $name", + (project) => { + test( + "fails if any en-US key has no source references", + { timeout: 30_000 }, + () => { + expect(fs.existsSync(project.localeFile)).toBe(true); -const IGNORED_DIRS = new Set(["tests", "__mocks__"]); -const IGNORED_FILE_PATTERNS = [ - /\.d\.ts$/, - /\.test\./, - /\.spec\./, - /\.stories\./, -]; -const PLURAL_SUFFIX_PATTERN = /_(zero|one|two|few|many|other)$/; + const { unused, localeCount } = findUnusedKeys(project); + expect(localeCount).toBeGreaterThan(project.minLocaleKeys ?? 1); // sanity -/** - * Keys that look unused to the heuristic but are genuinely used: keep them. - * These are families assembled at runtime, so no static fragment ever reaches - * source code for the literal/template matching to catch. Add a regex here - * (with a comment naming the runtime usage) rather than teaching the test - * about specific component internals. For a single key, anchor it: /^a\.b$/. - */ -const IGNORED_KEY_PATTERNS: RegExp[] = [ - // SignSettings / SavedSignaturesSection look up every key as - // t(`${translationScope}.${key}`); the scope ("sign" | "addText" | - // "addImage") and the relative key only ever exist as separate literals. - /^(sign|addText|addImage)\./, - // SettingsSearchBar builds its search index by loading whole subtrees via - // t(prefix, { returnObjects: true }); the leaf keys never appear in source. - /^admin\.settings\./, - /^settings\./, - /^account\./, -]; + const localeRelative = path + .relative(REPO_ROOT, project.localeFile) + .replace(/\\/g, "/"); -const flattenKeys = ( - node: unknown, - prefix = "", - acc = new Set(), -): Set => { - if (!node || typeof node !== "object" || Array.isArray(node)) { - if (prefix) { - acc.add(prefix); - } - return acc; - } - - for (const [childKey, value] of Object.entries( - node as Record, - )) { - const next = prefix ? `${prefix}.${childKey}` : childKey; - flattenKeys(value, next, acc); - } - - return acc; -}; - -const listSourceFiles = (): string[] => { - const files = ts.sys.readDirectory( - SRC_ROOT, - [".ts", ".tsx", ".js", ".jsx"], - undefined, - ["**/*"], - ); - - return files - .filter( - (file) => - !file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)), - ) - .filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file))); -}; - -const getScriptKind = (file: string): ts.ScriptKind => { - if (file.endsWith(".tsx")) return ts.ScriptKind.TSX; - if (file.endsWith(".ts")) return ts.ScriptKind.TS; - if (file.endsWith(".jsx")) return ts.ScriptKind.JSX; - return ts.ScriptKind.JS; -}; - -/** - * Walk each file's AST and collect every template literal whose static parts - * could plausibly form a dotted translation key. Each shape replaces ${...} - * interpolations with `*`, e.g. `tools.${id}.title` becomes `tools.*.title`. - * - * We deliberately collect *all* template literals (not just those at t() - * call sites), because keys are often built up in helpers, constants or - * config objects and only passed to t() somewhere far away. A shape only - * counts if it carries at least one identifier-like static fragment though, - * so generic templates like `${name}.${ext}` (shape `*.*`) are discarded. - * - * Using the AST (rather than a backtick-pair regex) is important: source - * files contain large multi-line templates with embedded CSS/HTML and - * nested interpolations that confuse regex-based pairing. - */ -const extractTemplateShapesFromFile = ( - file: string, - acc: Set, -): void => { - const code = fs.readFileSync(file, "utf8"); - if (!code.includes("${")) return; - - const sourceFile = ts.createSourceFile( - file, - code, - ts.ScriptTarget.Latest, - false, - getScriptKind(file), - ); - - const visit = (node: ts.Node): void => { - if (ts.isTemplateExpression(node)) { - let shape = node.head.text; - for (const span of node.templateSpans) { - shape += "*"; - shape += span.literal.text; - } - if ( - shape.includes(".") && - /[A-Za-z0-9_-]/.test(shape.replace(/\*/g, "")) - ) { - acc.add(shape); - } - } - ts.forEachChild(node, visit); - }; - - ts.forEachChild(sourceFile, visit); -}; - -const shapeToMatcher = (shape: string): RegExp => { - // Each * stands in for one runtime-supplied path segment. We use `[^.]+` - // (not `.+`) so a one-variable interpolation doesn't accidentally span - // multiple key levels. If a real interpolation does carry a multi-segment - // string, the IGNORED_KEY_PATTERNS list is the escape hatch. - const escaped = shape - .split("*") - .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) - .join("[^.]+"); - return new RegExp(`^${escaped}$`); -}; - -const isIgnored = (key: string): boolean => { - return IGNORED_KEY_PATTERNS.some((re) => re.test(key)); -}; - -const getTranslationLookupKeys = (key: string): string[] => { - const pluralBaseKey = key.replace(PLURAL_SUFFIX_PATTERN, ""); - if (pluralBaseKey === key) { - return [key]; - } - - return [key, pluralBaseKey]; -}; - -describe("Unused translation coverage", () => { - test( - "fails if any en-US translation key has no source references", - { timeout: 30_000 }, - () => { - expect(fs.existsSync(EN_US_FILE)).toBe(true); - - const enUs = parse(fs.readFileSync(EN_US_FILE, "utf8")); - const availableKeys = Array.from(flattenKeys(enUs)); - expect(availableKeys.length).toBeGreaterThan(100); // sanity check - - const sourceFiles = listSourceFiles(); - expect(sourceFiles.length).toBeGreaterThan(0); - - const source = sourceFiles - .map((file) => fs.readFileSync(file, "utf8")) - .join("\n"); - - const shapes = new Set(); - for (const file of sourceFiles) { - extractTemplateShapesFromFile(file, shapes); - } - const shapeMatchers = Array.from(shapes).map(shapeToMatcher); - - const unused = availableKeys.filter((key) => { - if (isIgnored(key)) return false; - const lookupKeys = getTranslationLookupKeys(key); - // Direct: the full key text appears anywhere in source (catches - // static t() calls, i18nKey props, constants, and any other place - // the literal string sits in code or comments). Plural variants also - // count as used when their base key is referenced because i18next - // resolves suffixes like _one/_other from a single base lookup. - if (lookupKeys.some((lookupKey) => source.includes(lookupKey))) { - return false; + // GitHub Annotations format so unused keys show up tagged on the locale. + for (const key of unused) { + process.stderr.write( + `::error file=${localeRelative}::Unused en-US translation: ${key}\n`, + ); } - // Dynamic: the key matches a template-literal shape from source. - return !lookupKeys.some((lookupKey) => - shapeMatchers.some((re) => re.test(lookupKey)), - ); - }); - const localeRelative = path - .relative(REPO_ROOT, EN_US_FILE) - .replace(/\\/g, "/"); - - // GitHub Annotations format so unused keys show up tagged on the - // translation file in CI. - for (const key of unused) { - process.stderr.write( - `::error file=${localeRelative}::Unused en-US translation: ${key}\n`, - ); - } - - expect( - unused, - `Found ${unused.length} unused en-US translation key(s). ` + - `Remove them from ${localeRelative}, or (if the usage is too ` + - `dynamic for the heuristic to spot) add to IGNORED_KEY_PATTERNS ` + - `in this test.`, - ).toEqual([]); - }, - ); -}); + expect( + unused, + `Found ${unused.length} unused en-US translation key(s). ` + + `Remove them from ${localeRelative}, or (if the usage is too ` + + `dynamic for the heuristic to spot) add a pattern to this ` + + `project's ignoredKeyPatterns in @shared/i18n/translationAudit.`, + ).toEqual([]); + }, + ); + }, +); diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index a176ef8541..444ca380b3 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -14,6 +14,7 @@ const srcGlobs = [ const nodeGlobs = [ "scripts/**/*.{js,ts,mjs,mts}", "editor/scripts/**/*.{js,ts,mjs,mts}", + "portal/scripts/**/*.{js,ts,mjs,mts}", "editor/*.config.{js,ts,mjs}", "portal/*.config.{js,ts,mjs}", "*.config.{js,ts,mjs}", diff --git a/frontend/portal/main.tsx b/frontend/portal/main.tsx index 76e802312f..a33ecdde36 100644 --- a/frontend/portal/main.tsx +++ b/frontend/portal/main.tsx @@ -1,8 +1,10 @@ /// -import { StrictMode } from "react"; +import { StrictMode, Suspense } from "react"; import { createRoot } from "react-dom/client"; import { App } from "@portal/App"; import { readMocksPreference } from "@portal/mocks/preference"; +// Initialise i18n (side effect) before the app renders. +import "@portal/i18n/config"; // Mantine's prebuilt styles load first so SUI tokens/base can override on // conflicts — SUI is the primary design language, Mantine the escape hatch. @@ -26,7 +28,9 @@ async function bootstrap(): Promise { createRoot(root!).render( - + + + , ); } diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml new file mode 100644 index 0000000000..11d80a4d79 --- /dev/null +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -0,0 +1,1739 @@ +# Portal translations — US English is the source of truth. Other languages are +# produced from this file by the Crowdin pipeline. Keys are grouped by +# view/feature; shared strings live under [common]. + +[common] +inviteMember = "Invite member" + +[users] +title = "Users" +subtitle = "The people in your organization and the access they hold — roles, status and security controls." + +[users.empty] +title = "No members yet" +description = "Invite your team to start collaborating on documents and pipelines." + +# Navigation item labels, keyed by view id. Drives both the sidebar nav and the +# header breadcrumb so the visible label has a single source. +[nav] +home = "Home" +editor = "Editor" +users = "Users" +sources = "Sources" +agent-builder = "Agent Builder" +policies = "Policies" +pipelines = "Pipelines" +documents = "Documents" +components = "Components" +infrastructure = "Infrastructure" +usage = "Usage & Billing" +docs = "Developer Docs" +settings = "Settings" + +[shell.header] +switchToDark = "Switch to dark theme" +switchToLight = "Switch to light theme" +darkMode = "Dark mode" +lightMode = "Light mode" +search = "Search" +searchPlaceholder = "Search…" + +[shell.sidebar] +brandSuffix = "Stirling Processor" +primaryNav = "Primary navigation" +switchApp = "Switch app" +appProcessor = "Processor" +appEditor = "Editor" +docsProcessed = "Docs processed" +docsCount = "{{docs}} docs" +planPayAsYouGo = "Pay-as-you-go" +planEnterprise = "Enterprise Plan" + +[search] +ariaLabel = "Search" +placeholder = "Search Stirling — endpoints, pipelines, docs…" + +[search.empty] +noMatches = "No matches for \"{{query}}\"" +noMatchesDescription = "Try a different keyword or browse the catalogue." +noActionsTitle = "No quick actions" +noActionsDescription = "Quick actions will appear here once they're available." + +[assistant] +open = "Open assistant" +close = "Close assistant" +title = "Assistant" +tryAsking = "Try asking" +typing = "Typing" +inputPlaceholder = "Ask about Stirling…" +inputAriaLabel = "Ask the assistant" +send = "Send" +error = "Couldn't reach the assistant." +errorWithDetail = "Couldn't reach the assistant: {{detail}}" + +[settings] +ariaLabel = "Settings" +footerNote = "Changes apply to this workspace." +cancel = "Cancel" +saveChanges = "Save changes" +enterpriseBadge = "Enterprise" + +[settings.groups] +account = "Account" +workspace = "Workspace" +admin = "Admin" + +[settings.sections] +profile = "Profile" +appearance = "Appearance" +notifications = "Notifications" +general = "General" +authentication = "Authentication" +sessions = "Active sessions" +early-access = "Early access" + +[settings.profile] +accountFallback = "Account" +changePhoto = "Change photo" +fullName = "Full name" +namePlaceholder = "Your name" +email = "Email" +emailHelper = "Used for sign-in and notification delivery." +emailPlaceholder = "you@company.com" + +[settings.appearance] +themeTitle = "Theme" +themeSub = "Choose how the portal looks on this device." + +[settings.appearance.light] +label = "Light" +hint = "Bright surfaces" + +[settings.appearance.dark] +label = "Dark" +hint = "Dim surfaces" + +[settings.notifications] +title = "Email notifications" +sub = "Pick which events reach your inbox." + +[settings.notifications.pipeline-failures] +label = "Pipeline failures" +description = "A run errors out or a step times out." + +[settings.notifications.pipeline-success] +label = "Pipeline completions" +description = "Every successful pipeline run finishes." + +[settings.notifications.usage-alerts] +label = "Usage & quota alerts" +description = "You approach a plan limit or rate cap." + +[settings.notifications.weekly-digest] +label = "Weekly digest" +description = "A Monday summary of volume and health." + +[settings.notifications.security-alerts] +label = "Security alerts" +description = "New API keys, sign-ins, or permission changes." + +[settings.notifications.product-updates] +label = "Product updates" +description = "New operations, sources, and release notes." + +[settings.workspace] +nameLabel = "Workspace name" +namePlaceholder = "Workspace name" +regionLabel = "Data residency region" +regionHelper = "Where documents are processed and stored at rest." +regionEnterpriseSuffix = "{{region}} · Enterprise" +plan = "Plan" +seats = "Seats" +seatsUsed = "{{used}} of {{total}} used" +manageBilling = "Manage billing" + +[settings.authentication] +title = "Sign-in policy" +sub = "Organisation-wide authentication controls." +sessionTimeout = "Session timeout" +sessionTimeoutHelper = "Members re-authenticate after this idle period." + +[settings.authentication.mfa] +label = "Enforce two-factor (MFA)" +description = "Require every member to complete MFA at sign-in." + +[settings.authentication.sso] +label = "Single sign-on (SAML)" +description = "Federate sign-in through your identity provider." + +[settings.authentication.scim] +label = "SCIM provisioning" +description = "Sync members and roles from your directory." + +[settings.authentication.timeout] +60 = "1 hour" +240 = "4 hours" +480 = "8 hours" +720 = "12 hours" +1440 = "24 hours" + +[settings.sessions] +title = "Active sessions" +sub = "Devices currently signed in to this account." +thisDevice = "This device" +revoke = "Revoke" + +[settings.earlyAccess] +title = "Preview features" +sub = "Opt into features still in preview." + +[componentsView] +title = "Components" +subtitle = "Embeddable SDK widgets you drop into your own app — a viewer, an e-sign flow, an AI review panel. Each is metered per action. Click a card for install, usage and props." + +[componentsView.lockedBanner] +title = "Some components need a paid plan" +description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge." + +[componentsView.empty] +title = "No components available" +description = "The component catalogue could not be loaded. Try again shortly." + +[pipelines] +title = "Pipelines" +subtitle = "Document workflows composed from typed operations — deployed, versioned, and continuously validated against a golden set." +newPipeline = "New pipeline" + +[pipelines.status] +healthy = "Healthy" +degraded = "Degraded" + +[pipelines.fleet] +healthy_one = "{{count}} healthy" +healthy_other = "{{count}} healthy" +degraded_one = "{{count}} degraded" +degraded_other = "{{count}} degraded" +deployed_one = "{{count}} deployed" +deployed_other = "{{count}} deployed" + +[pipelines.evals] +title = "Shadow + comparative evals active" +body_one = "{{count}} pipeline running a shadow eval, {{comparativeCount}} in a comparative run. {{detail}}" +body_other = "{{count}} pipelines running a shadow eval, {{comparativeCount}} in a comparative run. {{detail}}" + +[pipelines.empty] +title = "No pipelines yet" +description = "Compose your first document workflow from the typed operation library — pick a source, chain the ops, and route the output." +action = "Build your first pipeline" + +[pipelines.reliability] +heading = "Golden-set reliability" +description = "Pass rate against each pipeline's golden set, judged against its own bound. Anything below bound shows amber or red." + +[pipelines.promoted] +heading = "Promoted from the Editor" +description = "Watch-folder flows built in the Editor and promoted into the portal. Promote one to a policy to apply its rules fleet-wide." +policyCreated = "Policy created" +promoteToPolicy = "Promote to policy" + +[pipelines.promoted.table] +sourceDocType = "Source doc type" +watchFolder = "Watch folder" +status = "Status" + +[pipelines.promoted.status] +deployed = "Deployed" +staged = "Staged" +review = "Needs review" + +[pipelines.table.header] +name = "Pipeline" +health = "Health" +goldenSet = "Golden set" +docs24h = "Docs / 24h" +version = "Version" + +[pipelines.table] +boundTooltip = "Bound: {{bound}}" + +[pipelines.metrics] +docs24h = "Docs / 24h" +throughput = "Throughput" +errorRate = "Error rate" +p95Latency = "P95 latency" +uptime = "Uptime" + +[pipelines.card] +stageTooltip_one = "{{label}}: {{count}} op" +stageTooltip_other = "{{label}}: {{count}} ops" +golden = "Golden {{passing}}/{{total}}" +drift_one = "{{count}} drift" +drift_other = "{{count}} drifts" + +[pipelines.composer] +title = "New pipeline" +subtitle = "Pick a source, compose the operation chain, then route the output." +cancel = "Cancel" +back = "Back" +deploy = "Deploy pipeline" +continue = "Continue" +quickAddBundles = "Quick-add bundles" +chainEmpty = "Add operations from the library below." +operationChain_one = "Operation chain ({{count}})" +operationChain_other = "Operation chain ({{count}})" +destination = "Destination" +alerts = "Alerts" + +[pipelines.composer.steps] +source = "Source" +operations = "Operations" +routing = "Routing" + +[pipelines.composer.anySource] +label = "Any source" +desc = "Accept documents from every connected channel" + +[pipelines.composer.opKind] +ingest = "Ingest" +validate = "Validate" +modify = "Modify" +secure = "Secure" +store = "Route / Store" +alert = "Alerts" + +[pipelines.composer.alert.email] +title = "Email on failure" +desc = "Notify the on-call list when error rate trips its bound" + +[pipelines.composer.alert.webhook] +title = "Webhook on completion" +desc = "POST a run summary to a URL you control" + +[pipelines.composer.alert.review] +title = "Route low-confidence to review" +desc = "Send docs under the confidence bound to a human queue" + +[pipelines.detail] +subtitle = "{{version}} · {{source}} → {{destination}}" + +[pipelines.detail.stages] +heading = "Pipeline stages" +description = "Every document flows through five stages between {{source}} and {{destination}}." +noOps = "No ops" + +[pipelines.detail.golden] +heading = "Golden-set validation" +passing = "{{passing}} of {{total}} passing" +lastRun = "last run {{lastRun}}" +barLabel = "Golden set {{passing}} of {{total}} passing" + +[pipelines.detail.drift] +heading = "Schema drift" +confidence = "{{delta}} conf" +docs_one = "{{count}} docs" +docs_other = "{{count}} docs" + +[pipelines.detail.drift.empty] +title = "No drift detected" +description = "Every document in the last 24h matched its inferred schema." + +[sources] +title = "Sources & Agents" +subtitle = "Every place documents flow into Stirling — agents, API clients, webhooks, connectors and more. Click a row for type-specific detail." + +[sources.actions] +agentBuilder = "Agent Builder" +connectSource = "Connect source" + +[sources.empty] +title = "No sources connected yet" +description = "Connect an agent, API client, webhook, connector or inbox to start feeding documents into your pipelines." + +[sources.kpi] +agentsActive = "Agents active" +scenarios = "Scenarios" +evalPassRate = "Eval pass rate (7d)" +docs24h = "Docs / 24h" + +[sources.table] +source = "Source" +status = "Status" +docs24h = "Docs / 24h" +docs30d = "Docs / 30d" +lastEvent = "Last event" +owner = "Owner" + +[sources.detail] +ownedBy = "{{type}} · owned by {{owner}}" +closeAriaLabel = "Close detail" + +[sources.agent] +model = "Model" +calls24h = "Calls / 24h" +errorRate = "Error rate" +escalations24h = "Escalations / 24h" +meanConfidence = "Mean confidence" +meanOutputConfidence = "Mean output confidence" +assignedPipelines = "Assigned pipelines" +scopes = "Scopes" +viewEvalRuns = "View eval runs" +pauseAgent = "Pause agent" + +[sources.apiClient] +secretKey = "Secret key" +rateLimit = "Rate limit" +createdBy = "Created by" +lastRotated = "Last rotated" +rateLimitWindow = "Rate-limit window" +usedPct = "{{pct}} used" +rateLimitUsage = "Rate-limit usage" +topEndpoints = "Top endpoints" +callsPer24h = "{{count}} / 24h" +rotateKey = "Rotate key" +revoke = "Revoke" + +[sources.webhook] +endpointUrl = "Endpoint URL" +authType = "Auth type" +successRate = "Success rate" +retries24h = "Retries / 24h" +recentDeliveries = "Recent deliveries" +sendTestEvent = "Send test event" +viewSigningSecret = "View signing secret" + +[sources.wizard] +title = "Connect a source" +subtitle = "Step {{current}} of {{total}} · {{label}}" +cancel = "Cancel" +back = "Back" +continue = "Continue" +configureNote = "Scopes, rate limits and IP allowlists can be tuned after the source is connected." +type = "Type" +defaultPipeline = "Default pipeline" +defaultPipelineValue = "Redact & Flatten" +initialState = "Initial state" +initialStateValue = "Paused" +region = "Region" + +[sources.wizard.steps] +chooseType = "Choose type" +configure = "Configure" +review = "Review & connect" + +[sources.wizard.configureLead] +before = "Configure your" +after = ". Point it at Stirling and attach a default pipeline — every document this source ingests runs through it automatically." + +[sources.wizard.reviewLead] +before = "Ready to connect a new" +after = ". It starts paused so you can verify the first few documents before going live." + +[usage] +title = "Usage & Billing" +subtitle = "Your last 30 days of processing, plan, and charges." + +[usage.chart.empty] +title = "No usage yet" +description = "Once documents are processed, your 30-day usage appears here." + +[usage.kpi.docsThisPeriod] +label = "Docs this period" +description = "of {{included}} included" + +[usage.kpi.costThisMonth] +label = "Cost this month" +description = "incl. {{fee}} platform" +freePlan = "free plan" + +[usage.kpi.nextBillingDate] +label = "Next billing date" +resetsMonthly = "resets monthly" +autoCharge = "auto-charge" + +[usage.kpi.remainingInPlan] +label = "Remaining in plan" +description = "docs before cap" + +[usage.kpi.commitUtilisation] +label = "Commit utilisation" +description = "of committed volume" + +[usage.kpi.overage] +label = "Overage (${{rate}}/doc)" +description_one = "{{docs}} doc past cap" +description_other = "{{docs}} docs past cap" + +[usage.currentPlan] +eyebrow = "Current plan" + +[usage.currentPlan.badge] +free = "Free" +pro = "Pay-as-you-go" +enterprise = "Committed" + +[usage.currentPlan.free] +progressLabel = "Free plan usage" + +[usage.currentPlan.free.capReached] +title = "You've hit your free plan cap" +body = "New documents are paused until next cycle. Upgrade to keep processing without interruption." + +[usage.currentPlan.free.approaching] +title = "Approaching your free plan cap" +body = "You're at {{pct}}% of 500 docs/month. Upgrade to pay-as-you-go to avoid a pause." + +[usage.currentPlan.pro] +platformFee = "Platform fee" +includedDocs = "Included docs" +overage = "Overage · {{docs}} docs @ ${{rate}}" +projected = "Projected this month" + +[usage.currentPlan.enterprise] +committedVolume = "Committed volume" +committedVolumeValue = "{{docs}} docs/mo" +drawnThisPeriod = "Drawn this period" +drawnThisPeriodValue = "{{docs}} docs" +effectiveRate = "Effective rate" +effectiveRateValue = "${{rate}} / doc" +monthlyDraw = "Monthly draw" + +[usage.currentPlan.actions] +upgrade = "Upgrade plan" +talkToSales = "Talk to sales" +adjustCommitment = "Adjust commitment" +downloadInvoices = "Download invoices" + +[usage.spendCap.free] +title = "Spend cap" +description = "The free plan can't accrue spend — your usage is hard-capped at 500 docs/month. Upgrade to pay-as-you-go to set a monthly spend cap." + +[usage.spendCap.enterprise] +title = "Spend controls" +description = "Spend is governed by your committed-volume contract. Overage terms and alert thresholds are managed with your account team." +badge = "Committed contract" +overage = "Overage billed at ${{rate}}/doc" + +[usage.spendCap.pro] +title = "Monthly spend cap" +subtitle = "Pause processing automatically when spend reaches your limit." +disable = "Disable cap" +enable = "Enable cap" +projected = "Projected {{projected}} of {{cap}} cap" +progressLabel = "Spend against cap" + +[usage.plans] +title = "Plans" +subtitle = "Move up or down at any time — changes take effect next cycle." + +[usage.planCard] +current = "Current" +yourPlan = "Your plan" +contactSales = "Contact sales" +choosePlan = "Choose plan" + +[usage.history] +title = "Billing history" +subtitle = "Line items from the current and prior billing cycles." +emptyRows = "No line items" + +[usage.history.columns] +date = "Date" +description = "Description" +docs = "Docs" +amount = "Amount" +status = "Status" + +[usage.history.status] +paid = "Paid" +due = "Due" +pending = "Pending" +refunded = "Refunded" + +[usage.history.empty] +title = "No billing history" +description = "Charges and credits appear here once your first cycle closes." + +[usage.upgrade] +notNow = "Not now" + +[usage.upgrade.free] +title = "Upgrade to keep processing" +subtitle = "Pay-as-you-go · $0.05 / doc" +body = "You're at the edge of the 500 doc/month free cap. Pay-as-you-go lifts the cap instantly — you only pay for what you process beyond the included 25,000 docs." +bullets = [ + "Lift the 500 doc/month cap immediately", + "25,000 docs included, then $0.05/doc", + "Unlimited pipelines, agents, and sources", + "Set a monthly spend cap to stay in control", +] +cta = "Switch to pay-as-you-go" + +[usage.upgrade.proToEnterprise] +title = "Move to a committed plan" +subtitle = "Enterprise · committed annual volume" +body = "Your overage is consistent month over month. A committed-volume contract lowers your effective per-doc rate and unlocks dedicated regions, SSO, and a named CSM." +bullets = [ + "Lower effective rate vs metered overage", + "Dedicated & on-prem region options", + "SSO, audit-log export, signed DPA", + "Named CSM and 99.99% SLA", +] +cta = "Talk to sales" + +[usage.upgrade.pro] +title = "You're already on pay-as-you-go" +subtitle = "Considering a committed plan?" +body = "Pay-as-you-go scales with usage. If your volume is steady, a committed-volume contract typically lowers your effective per-doc rate." +bullets = [ + "Predictable monthly spend", + "Lower effective per-doc rate at volume", + "Volume discounts kick in past 1M docs/mo", +] +cta = "Explore committed pricing" + +[usage.upgrade.enterprise] +title = "Adjust your commitment" +subtitle = "Enterprise · bespoke terms" +body = "Your plan is governed by a committed-volume contract. Changes to committed volume, regions, or terms are handled with your account team — they'll model the right shape with you." +bullets = [ + "Re-model committed volume up or down", + "Add dedicated or on-prem regions", + "Adjust SLA, DPA, and overage terms", +] +cta = "Contact your CSM" + +[documents] +title = "Documents" +subtitle = "Review and approve documents moving through your pipelines." + +[documents.summary] +inQueue = "In queue" +needsReview = "Needs review" +avgConfidence = "Avg confidence" +processedToday = "Processed today" + +[documents.filters] +all = "All" +needsReview = "Needs review" +processed = "Processed" +archived = "Archived" +ariaLabel = "Filter documents by status" + +[documents.queue.empty] +title = "No documents in the queue" +description = "As sources feed documents into your pipelines they'll appear here for review." + +[documents.table] +empty = "No documents match this filter." +sensitiveTitle = "Sensitive — access required" +sensitiveLabel = "Sensitive" + +[documents.table.columns] +name = "Name" +type = "Type" +status = "Status" +source = "Source" +confidence = "Confidence" +fields = "Fields" +time = "Time" + +[documents.drawer] +sectionsAriaLabel = "Document detail sections" + +[documents.drawer.tabs] +overview = "Overview" +extractions = "Extractions" +audit = "Audit" + +[documents.overview] +status = "Status" +type = "Type" +confidence = "Confidence" +fieldsExtracted = "Fields extracted" +source = "Source" +received = "Received" + +[documents.extractions] +masked = "Extracted fields are hidden. Request timed access to view this document's content." +empty = "No fields were extracted from this document." + +[documents.extractions.columns] +field = "Field" +value = "Value" +confidence = "Confidence" + +[documents.audit] +empty = "No events recorded yet." + +[documents.elevation] +requestAccess = "Request access" + +[documents.elevation.active] +title = "Access expires in {{time}}" +description = "Temporary grant — access is logged and time-boxed." +descriptionFourEyes = "Temporary grant — a peer reviewer was notified (four-eyes)." + +[documents.elevation.gated] +title = "Sensitive document" +description = "Content is gated by zero-standing-access. Requesting starts a time-boxed grant." +descriptionFourEyes = "Content is gated by zero-standing-access. Requesting starts a time-boxed grant and notifies a peer reviewer (four-eyes)." + +[agentBuilder] +title = "Agent Builder" +subtitle = "Design, test and ship the AI agents that classify, extract from and route your documents. Define scenarios, fence tool access, run a golden set, and publish a version." +bootstrapFromDocument = "Bootstrap from document" +sectionsAriaLabel = "Agent builder sections" +selectorAriaLabel = "Agents" + +[agentBuilder.empty] +title = "No agents yet" +description = "Bootstrap an agent from a sample document to seed its scenarios and extraction schema, then refine and publish." + +[agentBuilder.tabs] +scenarios = "Scenarios" +tools = "Tools" +evals = "Evals" +versions = "Versions" + +[agentBuilder.kpi] +activeAgents = "Active agents" +avgPassRate = "Avg eval pass rate" +scenarios = "Scenarios" +latestPublished = "Latest published" +totalDescription_one = "{{count}} total" +totalDescription_other = "{{count}} total" +acrossGoldenSets = "across golden sets" +testCases = "test cases" +fleetWide = "fleet-wide" + +[agentBuilder.bootstrap] +title = "Bootstrap from a document" +subtitle = "Seed a new agent from one representative file" +cancel = "Cancel" +submit = "Bootstrap agent" +lead = "Drop a sample document and we'll propose scenarios and an extraction schema you can refine. Nothing is published until you review it." +dropzoneText = "Choose a sample document (PDF or image)" + +[agentBuilder.scenarios] +inEval = "in eval" +muted = "muted" +mute = "Mute" +enable = "Enable" +addScenario = "Add scenario" +nameLabel = "Name" +namePlaceholder = "e.g. Compliance escalation" +expectationLabel = "Expected behaviour" +expectationPlaceholder = "What the agent should do" +add = "Add" + +[agentBuilder.tools] +restrictedAccess = "Restricted tool access" +restrictedDescription = "Allow every tool except the ones you deny below." +governanceGate = "Tool governance is available on the Enterprise plan." +restricted = "Restricted" +broadAccess = "Broad access" +deniedTools = "Denied tools" +deniedHint = "Selected tools are blocked. Everything else stays callable." + +[agentBuilder.evals] +columnCase = "Eval case" +columnResult = "Result" +columnLatency = "Latency" +notRun = "not run" +pass = "pass" +fail = "fail" +latencyMs = "{{ms}} ms" +passRate = "Pass rate" +casesPassing = "Cases passing" +runEvals = "Run evals" +goldenSetPassRate = "Golden-set pass rate" + +[agentBuilder.evals.empty] +title = "No golden set yet" +description = "Evals turn your scenarios into a repeatable golden set. Upgrade to capture pass-rate over time and gate publishes on it." + +[agentBuilder.versions] +current = "current" +publish = "Publish" +rollBack = "Roll back" +historyGate = "Full version history and rollback are available on the Enterprise plan." + +[home.productGrid] +ariaLabel = "Process PDFs at scale" + +[home.productGrid.sources] +title = "Sources" +blurb = "Attach pipelines where PDFs already live — S3, agents, SharePoint, webhooks, batch, email." +cta = "Connect a source" + +[home.productGrid.pipelines] +badge = "Hero" +title = "Pipelines" +blurb = "Compose document workflows from typed operations. Upload a sample to get suggestions or start blank." +cta = "Build a pipeline" + +[home.productGrid.agents] +title = "Agents" +blurb = "Wire your agent via MCP, REST, or tool definitions. Deterministic operations, scenarios, evals." +cta = "Connect an agent" + +[home.quickActions] +title = "Quick actions" +subtitle = "Top tasks for today" + +[home.quickActions.tryOp] +title = "Try a PDF operation" +blurb = "Drop a sample, pick an op, see the JSON" + +[home.quickActions.buildPipeline] +title = "Build a pipeline" +blurb = "3-step composer over the typed op library" + +[home.quickActions.connectSource] +title = "Connect a source" +blurb = "S3, agents, webhooks, watched folders" + +[home.quickActions.issueApiKey] +title = "Issue an API key" +blurb = "Scoped key with rate limits and IP allowlist" + +[home.onboarding] +title = "Get to value" +subtitle = "Four steps to a production-shaped Stirling project." +progress = "{{done}} / {{total}} done" +runAgain = "Run again" +start = "Start" + +[home.onboarding.empty] +title = "No onboarding steps yet" +description = "Onboarding tasks will appear here once your workspace is set up." + +[home.kpis.free] +docsProcessed = "Docs processed" +docsProcessedDescription = "Free plan cap" +operations = "Operations" +pipelines = "Pipelines" +agents = "Agents" + +[home.kpis.pro] +docs30d = "Docs / 30d" +pipelines = "Pipelines" +agentsActive = "Agents active" +evalPassRate = "Eval pass rate" + +[home.kpis.enterprise] +docs30d = "Docs / 30d" +p95Latency = "P95 latency" +evalPassRate = "Eval pass rate" +slaUptime = "SLA uptime (30d)" + +[home.regions] +title = "Region health" +subtitle = "Real-time status for every deployed Stirling region." + +[home.regions.empty] +title = "No regions yet" +description = "Once a region is deployed, its health appears here." + +[home.chart.empty] +title = "No usage yet" +description = "Once documents are processed, your 30-day usage appears here." + +[editorAdmin] +title = "Editor deployment" +subtitle = "Deploy the Stirling PDF Editor, pair self-hosted instances to your org, and operate the running fleet — targets, health, credentials, and offline activation in one place." + +[editorAdmin.sections.targets] +title = "Deployment targets" +sub = "Where the Editor runs. Copy a snippet to stand up a self-hosted instance, or use Managed Cloud with no ops." + +[editorAdmin.sections.pairing] +title = "Pairing" +sub = "Connect a self-hosted editor to this org. Generate a token, hand off a short code, or wire it through IaC." + +[editorAdmin.sections.health] +title = "Instance health" +sub = "Every Editor instance reporting in — version, region, status, last seen, and active users." + +[editorAdmin.serviceToken] +title = "Service token" +subtitle = "Instances authenticate to the org with this credential. Rotate it on a schedule or immediately after a suspected leak." +currentToken = "Current token" +lastRotated = "Last rotated" +rotateButton = "Rotate service token" + +[editorAdmin.serviceToken.rotatedBanner] +title = "Rotate running instances" +description = "A new token was issued. Update each self-hosted instance's STIRLING_SERVICE_TOKEN within the 24h grace window or they'll drop offline." + +[editorAdmin.targets] +talkToSales = "Talk to sales" +upgradePlan = "Upgrade plan" +instanceCount_one = "{{count}} instance" +instanceCount_other = "{{count}} instances" + +[editorAdmin.targets.state] +running = "Running" +available = "Available" +locked = "Locked" + +[editorAdmin.targets.lock] +enterprise = "On-prem and Kubernetes self-hosting are part of Enterprise." +paid = "Self-hosting with Docker and Kubernetes unlocks on a paid plan." + +[editorAdmin.pairing] +lockCopy = "IaC provisioning is part of Enterprise." +talkToSales = "Talk to sales" +generated = "Generated ✓" +generateNewCode = "Generate new code" +rotate = "Rotate" + +[editorAdmin.health.columns] +host = "Host" +version = "Version" +region = "Region" +status = "Status" +lastSeen = "Last seen" +activeUsers = "Active users" + +[editorAdmin.health.empty] +title = "No instances reporting" +description = "Deploy a target and pair it to see live instance health here." + +[editorAdmin.offlineActivation] +title = "Air-gapped activation" +enterpriseTag = "Enterprise" +subtitle = "Generate a signed activation bundle for an offline or on-prem install with no outbound network path. Transfer it to the instance and apply it during first-run setup." +lockCopy = "Offline and on-prem activation is part of Enterprise." +talkToSales = "Talk to sales" +generateButton = "Generate offline bundle" + +[editorAdmin.offlineActivation.readyBanner] +title = "Bundle ready" +description = "{{file}} is signed and ready to transfer. It activates one instance and expires in 14 days." + +[policies] +title = "Policies" +subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original." + +[policies.status] +active = "Active" +paused = "Paused" + +[policies.stats] +docsEnforced = "Docs enforced" +dataProcessed = "Data processed" +activeFor = "Active" + +[policies.summary.active] +label = "Active policies" +description = "Enforcing on upload/export" + +[policies.summary.paused] +label = "Paused" +description = "Configured but not firing" + +[policies.summary.categories] +label = "Categories" +description = "Available to configure" + +[policies.summary.docsEnforced] +label = "Docs enforced" +description = "Across active policies" + +[policies.card] +comingSoon = "Coming soon" +notSetUp = "Not set up" +setUp = "Set up →" + +[policies.detail] +title = "{{category}} policy" +meta = "Runs on {{event}} · output {{output}}" +outputAsNewFile = "as a new file" +outputAsNewVersion = "as a new version" +enforces = "Enforces" +enforceNote = "{{scope}} · originals stay untouched, the enforced version is saved alongside." +recentActivity = "Recent activity" + +[policies.detail.actions] +delete = "Delete" +runNow = "Run now" +resume = "Resume" +pause = "Pause" +editSettings = "Edit settings" + +[policies.detail.emptyActivity] +title = "No activity yet" +description = "Documents will appear here once this policy runs." + +[policies.detail.scoped] +title = "Scoped" +description = "Limited to: {{types}}" + +[policies.wizard.title] +edit = "Edit {{category}} policy" +setUp = "Set up {{category}} policy" + +[policies.wizard.actions] +cancel = "Cancel" +continue = "Continue" +back = "Back" +saveChanges = "Save changes" +enablePolicy = "Enable policy" + +[policies.wizard.errors] +noTools = "Enable at least one tool in the workflow first." +saveFailed = "Couldn't save the policy. Please try again." + +[policies.wizard.tabs] +ariaLabel = "Setup steps" +workflow = "Workflow" +settings = "Settings" + +[policies.wizard.workflow] +description = "The sequence of tools this policy runs on each document. Each tool is a Stirling endpoint; toggle the ones this policy should enforce." + +[policies.wizard.settings] +heading = "Settings" + +[policies.wizard.sources] +heading = "Sources" + +[policies.wizard.docTypes] +heading = "Document types" +allTitle = "All document types" +allDescription = "Set up an Ingestion (classification) policy to narrow this to specific document types." +selected_one = "{{count}} selected" +selected_other = "{{count}} selected" +clear = "Clear" +narrow = "Narrow" + +[policies.wizard.output] +heading = "Output & run" + +[policies.wizard.output.runOn] +label = "Run on" +helper = "When the policy fires: on upload, or before export." +upload = "Upload" +export = "Export" + +[policies.wizard.output.outputAs] +label = "Output as" +newVersion = "New version" +newFile = "New file" + +[policies.wizard.output.filenameRule] +label = "Filename rule" +prefix = "Prefix" +suffix = "Suffix" +autoNumber = "Auto-number" +placeholder = "Text to add (optional)" + +[policies.wizard.output.reviewerEmail] +label = "Reviewer email" +helper = "Low-confidence enforcements are routed here for review." + +[users.summary] +members = "Members" +pendingInvites = "Pending invites" +seatsUsed = "Seats used" + +[users.table] +member = "Member" +role = "Role" +status = "Status" +lastActive = "Last active" +actionsFor = "Actions for {{name}}" +changeRole = "Change role" +suspend = "Suspend" +remove = "Remove from org" + +[users.invite] +subtitle = "They'll receive an email to join your organization." +cancel = "Cancel" +send = "Send invite" +email = "Email" +emailPlaceholder = "teammate@acme.com" +emailError = "Enter a valid email address" +role = "Role" +roleHelper = "Determines what the member can do once they join." + +[users.roles] +title = "Roles" +subtitle = "Every role exists on every plan — what each one can do is fixed across the org." + +[users.access] +title = "Access & security" +subtitle = "Seats, authentication and provisioning for your organization." + +[users.access.seats] +title = "Seats" +unlimited = "Your plan includes unlimited seats." +usedLabel = "{{used}} of {{limit}} seats used" + +[users.access.auth] +title = "Authentication" + +[users.access.auth.requireMfa] +label = "Require MFA" +enforced = "Enforced org-wide on this plan." +description = "Members must set up a second factor to sign in." + +[users.access.auth.shortSessions] +label = "Short-lived sessions" +description = "Sign members out after inactivity (currently {{timeout}})." + +[users.access.sso] +title = "SSO / SAML" +connected = "Connected" +notConfigured = "Not configured" +provider = "Provider" +domains = "Domains" +manage = "Manage connection" + +[users.access.scim] +title = "SCIM provisioning" +active = "Active" +off = "Off" +directory = "Directory" +lastSync = "Last sync" +note = "Members are created, updated and deactivated automatically from your identity provider." + +[users.access.upgrade] +title = "Unlock team access controls" +action = "Upgrade plan" + +[docs.nav] +ariaLabel = "Documentation" + +[docs.nav.empty] +title = "Docs unavailable" +description = "The documentation index could not be loaded." + +[docs.authentication] +eyebrow = "GETTING STARTED" +title = "Authentication" +lead = "All requests authenticate with a bearer token. Keys are scoped per environment and never expire unless rotated." +codeCaption = "every request" +liveKey = "Production keys — billed, rate-limited per your plan." +testKey = "Sandbox keys — free, return synthetic fixtures." + +[docs.components] +eyebrow = "COMPONENTS" +title = "Drop-in viewers" +lead = "Embeddable UI for review queues and document inspection. Bring your own styles or use the shipped theme." +codeCaption = "embed the viewer" + +[docs.endpoints] +eyebrow = "API REFERENCE" +title = "Endpoints" +lead = "Every document type is a typed endpoint. POST a file, receive schema-validated JSON. Filter by vertical below." +filterAll = "All" +filterAriaLabel = "Filter endpoints by vertical" +fieldCount_one = "{{count}} field" +fieldCount_other = "{{count}} fields" + +[docs.errors] +eyebrow = "API REFERENCE" +title = "Errors" +lead = "Errors return a stable machine-readable code plus a human message. The 4xx body always includes a request_id for support." +codeCaption = "422 Unprocessable Entity" + +[docs.quickstart] +eyebrow = "GETTING STARTED" +title = "Quickstart" +lead = "Send your first document to a typed endpoint and get structured JSON back in three steps. No model training, no prompt engineering." + +[docs.quickstart.step1] +title = "Issue an API key" +body = "Create a scoped key from the Infrastructure tab. Keys carry rate limits and an optional IP allowlist. Export it into your shell:" + +[docs.quickstart.step2] +title = "Send a document" +body = "POST a file to any typed endpoint. The endpoint determines the schema you get back — here, the invoice extractor." +snippetCaption = "extract an invoice" + +[docs.quickstart.step3] +title = "Read the structured result" +body = "Every response is validated against the endpoint schema, with a confidence score and per-field provenance." +codeCaption = "200 OK" + +[docs.quickstart.callout] +label = "Next:" +bodyBeforeLink = "wire the same call into a pipeline to chain validation, redaction, and delivery — or expose it to an agent over MCP. See" +link = "Playbooks" +bodyAfterLink = "for copy-paste recipes." + +[docs.recipes] +eyebrow = "PLAYBOOKS" +title = "Recipes" +lead = "End-to-end patterns that chain sources, operations, and destinations. Each maps to a pipeline you can clone." +cloneButton = "Clone recipe" + +[docs.rateLimits] +eyebrow = "GETTING STARTED" +title = "Rate limits & quotas" +lead = "Limits scale with your plan. A 429 response includes a Retry-After header; the SDKs back off automatically." +requestsPerMinute = "Requests / minute" +burst = "Burst" +concurrency = "Concurrency" +codeCaption = "429 Too Many Requests" + +[docs.sdks] +eyebrow = "SDKS" +title = "Official SDKs" +lead = "First-party clients with typed responses, automatic retries, and streaming uploads. All track the same endpoint catalogue." + +[docs.sdks.status] +beta = "Beta" +deprecated = "Deprecated" + +[docs.skills] +eyebrow = "SKILLS" +title = "Agent skills" +lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached." + +[docs.webhooks] +eyebrow = "API REFERENCE" +title = "Webhooks" +lead = "Subscribe to document.processed, pipeline.completed, and quota.threshold events. Payloads are signed with HMAC-SHA256." +codeCaption = "document.processed" + +[docs.webhooks.callout] +beforeSignature = "Verify the" +beforeHelper = "header against your signing secret before trusting a payload. SDKs ship a" +afterHelper = "helper." + +[catalogue.card] +openAriaLabel = "Open {{name}} component" +lockedAriaLabel = "Locked" + +[catalogue.detail] +ariaLabel = "Component detail" +tabsAriaLabel = "Component detail sections" +addToProject = "Add to project" +upgradeToUnlock = "Upgrade to unlock" + +[catalogue.detail.tabs] +overview = "Overview" +code = "Code" +props = "Props / API" +pricing = "Pricing" + +[catalogue.detail.locked] +title = "Not available on your plan" +description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it." + +[catalogue.detail.preview] +badge = "Live preview" +note = "Interactive sandbox renders here" + +[catalogue.detail.stats] +maturity = "Maturity" +price = "Price" +freeQuota = "Free quota" +freeQuotaValue = "{{amount}} / mo" +none = "None" +embeds30d = "Embeds (30d)" +perAction = "Per action" +billedOn = "Billed on" + +[catalogue.detail.code] +install = "Install" +usage = "Usage" + +[catalogue.detail.pricing] +note = "Metered per {{unit}}. Usage beyond the monthly free quota is billed to your account and itemised under Usage & Billing." + +[catalogue.props] +required = "required" +optional = "optional" + +[catalogue.props.columns] +name = "Prop" +type = "Type" +required = "Required" +description = "Description" + +[catalogue.summary] +componentsGa = "Components GA" +inBeta = "In beta" +embedsThisMonth = "Embeds this month" +componentSpendMtd = "Component spend (MTD)" + +[infrastructure] +title = "Infrastructure" +subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace." +manageEditorDeployment = "Manage Editor deployment" +sectionsAriaLabel = "Infrastructure sections" + +[infrastructure.tabs] +deployments = "Deployments" +apiKeys = "API Keys" +security = "Security" +models = "Models" +storage = "Storage" +audit = "Audit Logs" + +[infrastructure.apiKeys] +heading = "API keys" +subheading = "Scoped credentials with per-key rate limits, permissions, and IP allowlists." +createKey = "Create key" + +[infrastructure.apiKeys.empty] +title = "No API keys yet" +description = "Create a scoped key to start calling the Stirling API." + +[infrastructure.apiKeys.card] +created = "Created" +lastUsed = "Last used" +rateLimit = "Rate limit" +rateLimitValue = "{{value}} req/min" +usageToday = "Usage today" +usageMonth = "Usage this month" +permissions = "Permissions" +allowedIps = "Allowed IPs" +anyIp = "Any IP (no allowlist)" + +[infrastructure.createKey] +title = "Create API key" +titleCreated = "Key created" +subtitle = "Scope the key to the minimum it needs. You can rotate or revoke at any time." +subtitleCreated = "Copy this secret now — it won't be shown again." +done = "Done" +cancel = "Cancel" +createKey = "Create key" +secretKeyCaption = "Secret key" +secretWarning = "Store this in a secrets manager. Stirling only ever stores a hash — there is no way to recover it later." +keyNameLabel = "Key name" +keyNamePlaceholder = "e.g. Production · ingest" +permissionsLabel = "Permissions" +ipAllowlistLabel = "IP allowlist" +ipAllowlistHelper = "Comma-separated CIDR ranges. Leave blank to allow any IP." + +[infrastructure.deployments] +msValue = "{{value}} ms" +throughputValue = "{{value}}/min" + +[infrastructure.deployments.regionColumns] +region = "Region" +latency = "Latency" +load = "Load" +status = "Status" +version = "Version" +uptime = "Uptime" +instances = "Instances" +throughput = "Throughput" +p99 = "P99" + +[infrastructure.deployments.deployColumns] +version = "Version" +environment = "Environment" +product = "Product" +status = "Status" +deployedBy = "Deployed by" +when = "When" + +[infrastructure.deployments.regions] +heading = "Regions" +subheading = "Live health for every deployed Stirling region — latency, load, and rollout version." + +[infrastructure.deployments.regions.empty] +title = "No regions deployed" +description = "Deployed regions appear here once your workspace is provisioned." + +[infrastructure.deployments.recent] +heading = "Recent deployments" +subheading = "The latest rollouts across products and environments." + +[infrastructure.audit] +heading = "Audit logs" +subheading = "Every authentication, configuration, and processing event across your workspace." +filterAriaLabel = "Filter audit events by category" +latencyValue = "{{value}} ms" +noEventsInCategory = "No events in this category." + +[infrastructure.audit.filters] +all = "All" +auth = "Auth" +config = "Config" +elevation = "Elevation" +processing = "Processing" +security = "Security" + +[infrastructure.audit.columns] +timestamp = "Timestamp" +event = "Event" +actor = "Actor" +target = "Target" +status = "Status" +latency = "Latency" + +[infrastructure.audit.metrics] +totalEvents = "Total events · 24h" +processing = "Processing" +elevation = "Elevation" +config = "Config" + +[infrastructure.audit.empty] +title = "No audit events" +description = "Workspace activity will appear here as it happens." + +[infrastructure.models] +heading = "Models" +subheading = "The model catalogue and routing that powers document processing across your workspace." +msValue = "{{value}} ms" + +[infrastructure.models.columns] +model = "Model" +type = "Type" +status = "Status" +load = "Load" +latency = "Latency" +cost = "Cost" +version = "Version" + +[infrastructure.models.routingColumns] +operation = "Operation" +default = "Default" +docType = "Document type" +routedTo = "Routed to" +modelForAria = "Model for {{operation}}" + +[infrastructure.models.metrics] +activeModels = "Active models" +avgLatency = "Avg latency" +monthlySpend = "Monthly model spend" +included = "Included" + +[infrastructure.models.catalogue] +heading = "Catalogue" +sub = "Managed models available to your workspace, with live latency and cost." +subEnterprise = "Managed, bring-your-own, and on-prem models — with per-region pinning available." + +[infrastructure.models.catalogue.empty] +title = "No models available" +description = "Models in your workspace's catalogue appear here." + +[infrastructure.models.byom] +title = "Bring your own model" +description = "Register an on-prem or self-hosted model and pin it to a region for data-residency-bound processing." + +[infrastructure.models.routing] +heading = "Routing rules" +sub = "Which model handles each operation. The default applies when no narrower rule matches." +subLocked = "Route operations to specific models — available on paid plans." +empty = "No routing rules configured." + +[infrastructure.models.routing.lockedBanner] +title = "Model routing is a paid feature" +description = "Upgrade to Pro to control which model handles each operation and document type." + +[infrastructure.security.empty] +title = "Security posture unavailable" +description = "Your workspace's security configuration will appear here." + +[infrastructure.security.access.stirling] +label = "Stirling-held keys" +description = "Stirling manages encryption keys. Simplest — zero key ops on your side." + +[infrastructure.security.access.byok] +label = "Bring your own key (BYOK)" +description = "Supply a key from your own KMS. Stirling encrypts with it but can still read." + +[infrastructure.security.access.hyok] +label = "Hold your own key (HYOK)" +description = "Keys never leave your KMS. Stirling holds only ciphertext." + +[infrastructure.security.residency.us] +label = "United States" +description = "us-east-1 · us-west-2" + +[infrastructure.security.residency.eu] +label = "European Union" +description = "eu-west-1 · GDPR data boundary" + +[infrastructure.security.residency.apac] +label = "Asia Pacific" +description = "ap-southeast-1" + +[infrastructure.security.ipColumns] +label = "Label" +cidr = "CIDR" +addedBy = "Added by" +added = "Added" + +[infrastructure.security.accessPolicy] +heading = "Document access policy" +subheading = "Controls who can decrypt processed documents at rest." + +[infrastructure.security.hyokBanner] +title = "Stirling cannot decrypt your documents" +description = "With HYOK, encryption keys never leave your KMS. Stirling stores and processes only ciphertext you can revoke at any time." + +[infrastructure.security.residencyHeader] +heading = "Data residency" +subheading = "Where documents are stored and processed." + +[infrastructure.security.keyManagement] +heading = "Encryption key management" +subheading = "Custody of the keys that encrypt documents at rest — who can decrypt, and how keys rotate." +rotateKey = "Rotate key" +keyId = "Key identifier" +algorithm = "Algorithm" +lastRotated = "Last rotated" +rotationPolicy = "Rotation policy" + +[infrastructure.security.managedBanner] +title = "Keys are managed by Stirling on your plan" +description = "Bring-your-own-key (BYOK) and hold-your-own-key (HYOK) custody are available on Enterprise. Upgrade to supply keys from your own KMS." + +[infrastructure.security.compliance] +heading = "Compliance" +subheading = "Attestations and certifications covering the Stirling platform." + +[infrastructure.security.attestations] +heading = "Compliance attestations" +subheading = "Framework-by-framework audit posture, with reports available on attested controls." +viewReport = "View report →" +noReport = "No report available" + +[infrastructure.security.ipAllowlist] +heading = "IP allowlist" +sub = "API access is restricted to these CIDR ranges." +subLocked = "Restrict API access to known IP ranges — available on paid plans." +empty = "No IP ranges configured — all IPs allowed." + +[infrastructure.security.ipAllowlist.lockedBanner] +title = "IP allowlisting is a paid feature" +description = "Upgrade to Pro to restrict API access to specific networks." + +[infrastructure.storage] +gbValue = "{{value}} GB" +percentUsed = "{{value}} used" + +[infrastructure.storage.retentionOption] +days_one = "{{count}} day" +days_other = "{{count}} days" +never = "Never delete" + +[infrastructure.storage.empty] +title = "No storage configured" +description = "Connected storage and usage appear here." + +[infrastructure.storage.totalUsage] +heading = "Total usage" +subheading = "Storage consumed across all connected providers." +progressLabel = "Storage used" + +[infrastructure.storage.providers] +heading = "Connected providers" +subheading = "Where processed artifacts are written." +connected = "Connected" +connect = "Connect" + +[infrastructure.storage.retention] +heading = "Retention" +subheading = "How long artifacts are kept before lifecycle deletion." +windowLabel = "Default retention window" + +[infrastructure.storage.lifecycle] +active = "Active" +activeRange = "0–{{value}}d" +archived = "Archived" +coldStorage = "cold storage" +deleted = "Deleted" +never = "never" +purged = "purged" + +[recentActivity] +title = "Recent activity" +viewAll = "View all" + +[recentActivity.empty] +title = "Nothing here yet" +description = "Pipeline runs, deploys and agent events will appear here." + +[useCases] +title = "Popular use cases" +viewAll = "View all pipelines" + +[useCases.items.autoRouting] +eyebrow = "AUTO-ROUTING" +title = "Auto-classify and route incoming documents" +blurb = "One classifier reads what arrived — KYC form, invoice, contract, COI — and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow." +cta = "Build a classifier pipeline" + +[useCases.items.piiRedaction] +eyebrow = "PII REDACTION" +title = "Redact PII before it leaves your stack" +blurb = "Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline." +cta = "See redaction pipelines" + +[useCases.items.trainingData] +eyebrow = "TRAINING DATA" +title = "Turn PDFs into training data" +blurb = "Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable." +cta = "Build a training-data pipeline" + +[useCases.items.authenticity] +eyebrow = "AUTHENTICITY" +title = "Verify signatures and detect tampering" +blurb = "Cryptographic checks at the document boundary — signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code." +cta = "Try authenticity check" + +[policySummary] +title = "What runs on your PDFs" +subtitle = "Standing automations every document passes through, regardless of which pipeline handles it." +activeSummary = "{{active}} / {{total}} active" +noRule = "No rule enforced yet" + +[policySummary.state] +active = "Active" +off = "Off" +soon = "Soon" + +[policySummary.column] +policy = "Policy" +status = "Status" +activeRule = "Active rule" + +[policySummary.action] +comingSoon = "Coming soon" +configure = "Configure" +setUp = "Set up" + +[policySummary.empty] +title = "No policies yet" +description = "Once policies are configured, the categories appear here." + +[opRunner] +title = "Try a PDF operation" +subtitle = "Drop a sample, pick an op, see what Stirling returns." +featuredOps = "Featured ops" +durationMs = "{{ms}} ms" + +[opRunner.action] +close = "Close" +runAgain = "Run again" +openBuilder = "Open the pipeline builder" +run = "Run operation" +running = "Running…" + +[opRunner.status] +failed = "Failed" +completed = "Completed" + +[opRunner.drop] +title = "Drop a PDF here" +hint = "or use a sample document." +replaceHint = "Drop again or pick another sample to replace." +pickAnother = "Pick another sample" +useSample = "Use a sample" + +[opRunner.empty] +title = "No featured ops yet" +description = "Once operations are published, they'll show up here." + +[opRunner.hint] +ready = "Ready" +aSample = "a sample" +press = "Press" +toInvoke = "to invoke" + +[opRunner.hint.runOn] +before = "Run" +middle = "on" + +[opRunner.running] +title = "Running {{label}}…" + +[opRunner.error] +title = "The operation didn't complete" +unknown = "Unknown error" + +[welcome] +ariaLabel = "Stirling product highlights" +pagination = "Carousel pagination" +slideLabel = "Slide {{number}}: {{title}}" + +[welcome.ornament.editor] +critical = "Critical" +signed = "signed" +ocrClean = "OCR-clean" +schemaMatch = "schema match 0.97" + +[welcome.slides.editor] +eyebrow = "PDF Editor" +title = "The #1 PDF Editor on GitHub" +sub = "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane." +primary = "Install PDF Editor" +secondary = "Connect an instance" + +[welcome.slides.platform] +eyebrow = "Platform" +title = "PDF Infrastructure for Developers" +sub = "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls." +primary = "Try a PDF operation" +secondary = "Get an API key" + +[welcome.slides.agents] +eyebrow = "AI Agents" +title = "PDF Processor for AI Agents" +sub = "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship." +primary = "Try PDF Processor" +secondary = "View MCP docs" + +[notifications] +title = "Notifications" +markAllRead = "Mark all read" +viewAll = "View all" + +[notifications.ariaLabel] +unread_one = "Notifications, {{count}} unread" +unread_other = "Notifications, {{count}} unread" +none = "Notifications, no unread" + +[notifications.count] +new_one = "{{count}} new" +new_other = "{{count}} new" +loading = "loading" +allRead = "all read" + +[notifications.empty] +title = "You're all caught up" +description = "No new notifications." + +[mocks.label] +on = "Mocks ON" +off = "Mocks OFF" + +[mocks.tooltip] +on = "Mock data ON — fetch calls are intercepted by MSW. Click to switch to the real network (reloads the page)." +off = "Mock data OFF — fetch calls go to the real network. Click to re-enable mocks (reloads the page)." + +[forkWizard] +title = "Fork a starter pipeline" +subtitle = "Clone a proven workflow and tune it — every template ships the same four-stage backbone." + +[forkWizard.status] +ready = "Ready to deploy" +building = "Building…" + +[forkWizard.action] +pickAnother = "Pick another" +cancel = "Cancel" +deploy = "Deploy pipeline" + +[processingStatus] +upgrade = "Upgrade" +pdfsThisMonth = "PDFs this month" +progressLabel = "{{used}} of {{cap}} PDFs used this month" +volumeSuffix = "PDFs processed · last 30 days" +managePlan = "Manage plan" + +[usageChart] +defaultLabel = "Docs processed · last 30 days" +delta = "{{pct}}% vs prior 30d" +docsValue = "{{value}} docs" +srAnnounce = "{{date}}: {{value}} docs" diff --git a/frontend/portal/src/components/AssistantButton.tsx b/frontend/portal/src/components/AssistantButton.tsx index 126d84ce68..e88130094b 100644 --- a/frontend/portal/src/components/AssistantButton.tsx +++ b/frontend/portal/src/components/AssistantButton.tsx @@ -1,17 +1,19 @@ +import { useTranslation } from "react-i18next"; import { useUI } from "@portal/contexts/UIContext"; import { SparklesIcon } from "@portal/components/icons"; import "@portal/components/AssistantButton.css"; export function AssistantButton() { const { assistantOpen, toggleAssistant } = useUI(); + const { t } = useTranslation(); return ( @@ -101,7 +109,7 @@ export function AssistantPanel() { {messages.length === 0 && suggestions && (
- Try asking + {t("assistant.tryAsking")}
{suggestions.map((s) => ( @@ -130,7 +138,10 @@ export function AssistantPanel() { ))} {typing && (
- + @@ -150,8 +161,8 @@ export function AssistantPanel() { ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} - placeholder="Ask about Stirling…" - aria-label="Ask the assistant" + placeholder={t("assistant.inputPlaceholder")} + aria-label={t("assistant.inputAriaLabel")} className="portal-assistant__input" disabled={typing} /> @@ -159,7 +170,7 @@ export function AssistantPanel() { type="submit" className="portal-assistant__send" disabled={!input.trim() || typing} - aria-label="Send" + aria-label={t("assistant.send")} > diff --git a/frontend/portal/src/components/Header.tsx b/frontend/portal/src/components/Header.tsx index 10c1607a00..9278cbd407 100644 --- a/frontend/portal/src/components/Header.tsx +++ b/frontend/portal/src/components/Header.tsx @@ -1,7 +1,8 @@ +import { useTranslation } from "react-i18next"; import { Avatar, Dropdown } from "@shared/components"; import { useTheme } from "@portal/contexts/ThemeContext"; import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext"; -import { useView, VIEW_LABELS } from "@portal/contexts/ViewContext"; +import { useView } from "@portal/contexts/ViewContext"; import { useUI } from "@portal/contexts/UIContext"; import { SearchIcon, @@ -15,15 +16,22 @@ import "@portal/components/Header.css"; function ThemeToggle() { const { theme, toggle } = useTheme(); + const { t } = useTranslation(); return ( @@ -71,22 +79,25 @@ function TierSwitcher() { export function Header() { const { activeView } = useView(); const { openSearch } = useUI(); + const { t } = useTranslation(); return (
- {VIEW_LABELS[activeView]} + {t(`nav.${activeView}`)}
); diff --git a/frontend/portal/src/components/NotificationsDropdown.tsx b/frontend/portal/src/components/NotificationsDropdown.tsx index 2445798cce..c93a2f9f33 100644 --- a/frontend/portal/src/components/NotificationsDropdown.tsx +++ b/frontend/portal/src/components/NotificationsDropdown.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Dropdown, EmptyState, Skeleton } from "@shared/components"; import { BellIcon } from "@portal/components/icons"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; @@ -20,6 +21,7 @@ const CATEGORY_COLOUR: Record = { }; export function NotificationsDropdown() { + const { t } = useTranslation(); const state = useAsync(() => fetchNotifications(), []); const { data: items } = state; const { isLoading } = useSectionFlags(state); @@ -48,8 +50,10 @@ export function NotificationsDropdown() { className="portal-header__icon-btn portal-header__icon-btn--badge" aria-label={ hasUnread - ? `Notifications, ${visible.length} unread` - : "Notifications, no unread" + ? t("notifications.ariaLabel.unread", { + count: visible.length, + }) + : t("notifications.ariaLabel.none") } > @@ -60,16 +64,20 @@ export function NotificationsDropdown() {
- Notifications + + {t("notifications.title")} + {hasUnread ? ( - {visible.length} new + + {t("notifications.count.new", { count: visible.length })} + ) : isLoading ? ( - loading + {t("notifications.count.loading")} ) : ( - all read + {t("notifications.count.allRead")} )}
@@ -84,8 +92,8 @@ export function NotificationsDropdown() { {isEmpty && ( )} {!isLoading && !isEmpty && ( @@ -113,10 +121,10 @@ export function NotificationsDropdown() { onClick={onMarkAllRead} disabled={!hasUnread} > - Mark all read + {t("notifications.markAllRead")}
diff --git a/frontend/portal/src/components/PipelineForkWizard.tsx b/frontend/portal/src/components/PipelineForkWizard.tsx index cd9e54e268..a357a6bb75 100644 --- a/frontend/portal/src/components/PipelineForkWizard.tsx +++ b/frontend/portal/src/components/PipelineForkWizard.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button, Card, Chip, StatusBadge } from "@shared/components"; import { useView } from "@portal/contexts/ViewContext"; import { @@ -20,6 +21,7 @@ type Phase = "pick" | "building" | "ready"; const STAGE_STEP_MS = 550; export function PipelineForkWizard() { + const { t } = useTranslation(); const { setActiveView } = useView(); const [phase, setPhase] = useState("pick"); const [template, setTemplate] = useState(null); @@ -68,15 +70,14 @@ export function PipelineForkWizard() {
-

Fork a starter pipeline

-

- Clone a proven workflow and tune it — every template ships the same - four-stage backbone. -

+

{t("forkWizard.title")}

+

{t("forkWizard.subtitle")}

{phase !== "pick" && template && ( - {phase === "ready" ? "Ready to deploy" : "Building…"} + {phase === "ready" + ? t("forkWizard.status.ready") + : t("forkWizard.status.building")} )}
@@ -139,7 +140,9 @@ export function PipelineForkWizard() {
diff --git a/frontend/portal/src/components/PolicySummary.tsx b/frontend/portal/src/components/PolicySummary.tsx index 37fe47deae..971ef88e1d 100644 --- a/frontend/portal/src/components/PolicySummary.tsx +++ b/frontend/portal/src/components/PolicySummary.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, Card, @@ -34,11 +35,11 @@ interface PolicyRow { const STATE_BADGE: Record< RowState, - { tone: "success" | "neutral" | "info"; label: string } + { tone: "success" | "neutral" | "info"; labelKey: string } > = { - active: { tone: "success", label: "Active" }, - off: { tone: "neutral", label: "Off" }, - locked: { tone: "info", label: "Soon" }, + active: { tone: "success", labelKey: "policySummary.state.active" }, + off: { tone: "neutral", labelKey: "policySummary.state.off" }, + locked: { tone: "info", labelKey: "policySummary.state.soon" }, }; function toRow(entry: CatalogueEntry): PolicyRow { @@ -48,6 +49,7 @@ function toRow(entry: CatalogueEntry): PolicyRow { } export function PolicySummary() { + const { t } = useTranslation(); const { setActiveView } = useView(); const state = useAsync(() => fetchPolicies(), []); const { data } = state; @@ -58,7 +60,7 @@ export function PolicySummary() { const columns: TableColumn[] = [ { key: "category", - header: "Policy", + header: t("policySummary.column.policy"), render: ({ entry }) => (
@@ -73,23 +75,25 @@ export function PolicySummary() { }, { key: "status", - header: "Status", + header: t("policySummary.column.status"), width: "7rem", render: ({ state }) => { const badge = STATE_BADGE[state]; return ( - {badge.label} + {t(badge.labelKey)} ); }, }, { key: "rule", - header: "Active rule", + header: t("policySummary.column.activeRule"), render: ({ entry, state }) => ( - {state === "active" ? entry.config.summary : "No rule enforced yet"} + {state === "active" + ? entry.config.summary + : t("policySummary.noRule")} ), }, @@ -102,7 +106,7 @@ export function PolicySummary() { if (state === "locked") { return ( ); } @@ -112,7 +116,9 @@ export function PolicySummary() { variant={state === "active" ? "ghost" : "outline"} onClick={goToPolicies} > - {state === "active" ? "Configure" : "Set up"} + {state === "active" + ? t("policySummary.action.configure") + : t("policySummary.action.setUp")} ); }, @@ -122,19 +128,23 @@ export function PolicySummary() { const rows: PolicyRow[] = data?.catalogue.map(toRow) ?? []; return ( -
+
-

What runs on your PDFs

+

+ {t("policySummary.title")} +

- Standing automations every document passes through, regardless of - which pipeline handles it. + {t("policySummary.subtitle")}

{data && ( - {data.summary.active} / {data.summary.categories} active + {t("policySummary.activeSummary", { + active: data.summary.active, + total: data.summary.categories, + })} )}
@@ -153,8 +163,8 @@ export function PolicySummary() { {isEmpty && ( )} diff --git a/frontend/portal/src/components/PopularUseCases.tsx b/frontend/portal/src/components/PopularUseCases.tsx index bdbeaff82f..6f99f74453 100644 --- a/frontend/portal/src/components/PopularUseCases.tsx +++ b/frontend/portal/src/components/PopularUseCases.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Card, type CardProps } from "@shared/components"; import { useView } from "@portal/contexts/ViewContext"; import "@portal/components/PopularUseCases.css"; @@ -5,10 +6,8 @@ import "@portal/components/PopularUseCases.css"; type Accent = NonNullable; interface UseCase { - eyebrow: string; - title: string; - blurb: string; - cta: string; + /** Stable key into the useCases.items.* translation table. */ + key: string; accent: Accent; } @@ -23,62 +22,35 @@ const ACCENT_COLOR: Record = { /** * Curated landing-page use cases — a teaser, not the full catalogue. The * exhaustive per-vertical endpoint list lives on the Documents view; here we - * surface the four cross-cutting pipelines people reach for first. Copy mirrors - * the prototype's "Popular use cases" block. + * surface the four cross-cutting pipelines people reach for first. The display + * copy (eyebrow, title, blurb, cta) is keyed into useCases.items.. */ const USE_CASES: UseCase[] = [ - { - eyebrow: "AUTO-ROUTING", - title: "Auto-classify and route incoming documents", - blurb: - "One classifier reads what arrived — KYC form, invoice, contract, COI — and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow.", - cta: "Build a classifier pipeline", - accent: "blue", - }, - { - eyebrow: "PII REDACTION", - title: "Redact PII before it leaves your stack", - blurb: - "Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline.", - cta: "See redaction pipelines", - accent: "red", - }, - { - eyebrow: "TRAINING DATA", - title: "Turn PDFs into training data", - blurb: - "Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable.", - cta: "Build a training-data pipeline", - accent: "purple", - }, - { - eyebrow: "AUTHENTICITY", - title: "Verify signatures and detect tampering", - blurb: - "Cryptographic checks at the document boundary — signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code.", - cta: "Try authenticity check", - accent: "green", - }, + { key: "autoRouting", accent: "blue" }, + { key: "piiRedaction", accent: "red" }, + { key: "trainingData", accent: "purple" }, + { key: "authenticity", accent: "green" }, ]; export function PopularUseCases() { + const { t } = useTranslation(); const { setActiveView } = useView(); return ( -
+
-

Popular use cases

+

{t("useCases.title")}

{USE_CASES.map((uc) => ( - {uc.eyebrow} + {t(`useCases.items.${uc.key}.eyebrow`)} -

{uc.title}

-

{uc.blurb}

+

+ {t(`useCases.items.${uc.key}.title`)} +

+

+ {t(`useCases.items.${uc.key}.blurb`)} +

))} diff --git a/frontend/portal/src/components/ProcessingStatusStrip.tsx b/frontend/portal/src/components/ProcessingStatusStrip.tsx index 4268eb3ee8..05adba3e40 100644 --- a/frontend/portal/src/components/ProcessingStatusStrip.tsx +++ b/frontend/portal/src/components/ProcessingStatusStrip.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Banner, Button, ProgressBar, Skeleton } from "@shared/components"; import { TIER_INFO, useTier } from "@portal/contexts/TierContext"; import { useView } from "@portal/contexts/ViewContext"; @@ -24,6 +25,7 @@ function parseUsage(value: KpiEntry["value"]): { } export function ProcessingStatusStrip() { + const { t } = useTranslation(); const { tier } = useTier(); const { setActiveView } = useView(); const { data: kpis, loading } = useAsync( @@ -58,7 +60,7 @@ export function ProcessingStatusStrip() { variant="outline" onClick={() => setActiveView("usage")} > - Upgrade + {t("processingStatus.upgrade")} ) : undefined } @@ -66,7 +68,7 @@ export function ProcessingStatusStrip() {
{used.toLocaleString()} / {cap.toLocaleString()}{" "} - PDFs this month + {t("processingStatus.pdfsThisMonth")} {Math.round(ratio * 100)}% @@ -75,7 +77,7 @@ export function ProcessingStatusStrip() { ); @@ -97,7 +99,7 @@ export function ProcessingStatusStrip() { · - {volume ?? "—"} PDFs processed · last 30 days + {volume ?? "—"} {t("processingStatus.volumeSuffix")}
); diff --git a/frontend/portal/src/components/RecentActivity.tsx b/frontend/portal/src/components/RecentActivity.tsx index 74a11f0420..024eb2f6ce 100644 --- a/frontend/portal/src/components/RecentActivity.tsx +++ b/frontend/portal/src/components/RecentActivity.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Card, EmptyState, Skeleton, StatusBadge } from "@shared/components"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; import { @@ -17,6 +18,7 @@ const KIND_COLOUR: Record = { }; export function RecentActivity() { + const { t } = useTranslation(); const state = useAsync(() => fetchRecentActivity(), []); const { data: events } = state; const { isLoading, isEmpty } = useSectionFlags(state); @@ -25,12 +27,12 @@ export function RecentActivity() {
-

Recent activity

+

{t("recentActivity.title")}

@@ -54,8 +56,8 @@ export function RecentActivity() { {isEmpty && ( )} diff --git a/frontend/portal/src/components/SearchModal.tsx b/frontend/portal/src/components/SearchModal.tsx index f0da1abeff..df27a86732 100644 --- a/frontend/portal/src/components/SearchModal.tsx +++ b/frontend/portal/src/components/SearchModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { EmptyState, Modal, Skeleton } from "@shared/components"; import { useUI } from "@portal/contexts/UIContext"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; @@ -7,6 +8,7 @@ import { SearchIcon } from "@portal/components/icons"; import "@portal/components/SearchModal.css"; export function SearchModal() { + const { t } = useTranslation(); const { searchOpen, closeSearch } = useUI(); const inputRef = useRef(null); const [query, setQuery] = useState(""); @@ -48,7 +50,7 @@ export function SearchModal() { open={searchOpen} onClose={closeSearch} width="lg" - ariaLabel="Search" + ariaLabel={t("search.ariaLabel")} >
@@ -57,8 +59,8 @@ export function SearchModal() { ref={inputRef} value={query} onChange={(e) => setQuery(e.target.value)} - placeholder="Search Stirling — endpoints, pipelines, docs…" - aria-label="Search" + placeholder={t("search.placeholder")} + aria-label={t("search.ariaLabel")} className="portal-search__input" autoComplete="off" spellCheck={false} @@ -82,13 +84,13 @@ export function SearchModal() { size="compact" title={ query.trim() - ? `No matches for "${query.trim()}"` - : "No quick actions" + ? t("search.empty.noMatches", { query: query.trim() }) + : t("search.empty.noActionsTitle") } description={ query.trim() - ? "Try a different keyword or browse the catalogue." - : "Quick actions will appear here once they're available." + ? t("search.empty.noMatchesDescription") + : t("search.empty.noActionsDescription") } /> )} diff --git a/frontend/portal/src/components/SettingsModal.tsx b/frontend/portal/src/components/SettingsModal.tsx index a770e9f587..0c9ea1d7d7 100644 --- a/frontend/portal/src/components/SettingsModal.tsx +++ b/frontend/portal/src/components/SettingsModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Avatar, Button, @@ -55,100 +56,26 @@ interface SettingsModalProps { onClose: () => void; } -/** Display copy for each notification category, keyed by the snapshot id. */ -const NOTIFICATION_COPY: Record< - string, - { label: string; description: string } -> = { - "pipeline-failures": { - label: "Pipeline failures", - description: "A run errors out or a step times out.", - }, - "pipeline-success": { - label: "Pipeline completions", - description: "Every successful pipeline run finishes.", - }, - "usage-alerts": { - label: "Usage & quota alerts", - description: "You approach a plan limit or rate cap.", - }, - "weekly-digest": { - label: "Weekly digest", - description: "A Monday summary of volume and health.", - }, - "security-alerts": { - label: "Security alerts", - description: "New API keys, sign-ins, or permission changes.", - }, - "product-updates": { - label: "Product updates", - description: "New operations, sources, and release notes.", - }, -}; +/** + * Notification categories with known display copy, in the order the snapshot + * exposes them. Labels and descriptions are resolved via i18n at render time, + * keyed by id; ids absent from this list are skipped. + */ +const NOTIFICATION_IDS = [ + "pipeline-failures", + "pipeline-success", + "usage-alerts", + "weekly-digest", + "security-alerts", + "product-updates", +] as const; -const SECTION_LABEL: Record = { - profile: "Profile", - appearance: "Appearance", - notifications: "Notifications", - general: "General", - authentication: "Authentication", - sessions: "Active sessions", - "early-access": "Early access", -}; - -const NAV_SECTIONS: SettingsNavSection[] = [ - { - title: "Account", - items: [ - { key: "profile", label: "Profile", icon: }, - { key: "appearance", label: "Appearance", icon: }, - { - key: "notifications", - label: "Notifications", - icon: , - }, - ], - }, - { - title: "Workspace", - items: [ - { key: "general", label: "General", icon: }, - ], - }, - { - title: "Admin", - items: [ - { - key: "authentication", - label: "Authentication", - icon: , - }, - { - key: "sessions", - label: "Active sessions", - icon: , - }, - { - key: "early-access", - label: "Early access", - icon: , - }, - ], - }, +const THEME_OPTIONS: { value: Theme }[] = [ + { value: "light" }, + { value: "dark" }, ]; -const THEME_OPTIONS: { value: Theme; label: string; hint: string }[] = [ - { value: "light", label: "Light", hint: "Bright surfaces" }, - { value: "dark", label: "Dark", hint: "Dim surfaces" }, -]; - -const SESSION_TIMEOUT_OPTIONS: SelectOption[] = [ - { value: "60", label: "1 hour" }, - { value: "240", label: "4 hours" }, - { value: "480", label: "8 hours" }, - { value: "720", label: "12 hours" }, - { value: "1440", label: "24 hours" }, -]; +const SESSION_TIMEOUT_VALUES = ["60", "240", "480", "720", "1440"] as const; /** * Account settings as a portal-wide overlay. A grouped left-nav (Account / @@ -157,10 +84,67 @@ const SESSION_TIMEOUT_OPTIONS: SelectOption[] = [ * writes straight through to ThemeProvider so the change is real and visible. */ export function SettingsModal({ open, onClose }: SettingsModalProps) { + const { t } = useTranslation(); const { tier } = useTier(); const { theme, setTheme } = useTheme(); const [section, setSection] = useState("profile"); + const navSections = useMemo( + () => [ + { + title: t("settings.groups.account"), + items: [ + { + key: "profile", + label: t("settings.sections.profile"), + icon: , + }, + { + key: "appearance", + label: t("settings.sections.appearance"), + icon: , + }, + { + key: "notifications", + label: t("settings.sections.notifications"), + icon: , + }, + ], + }, + { + title: t("settings.groups.workspace"), + items: [ + { + key: "general", + label: t("settings.sections.general"), + icon: , + }, + ], + }, + { + title: t("settings.groups.admin"), + items: [ + { + key: "authentication", + label: t("settings.sections.authentication"), + icon: , + }, + { + key: "sessions", + label: t("settings.sections.sessions"), + icon: , + }, + { + key: "early-access", + label: t("settings.sections.early-access"), + icon: , + }, + ], + }, + ], + [t], + ); + const { data: snapshot, loading } = useAsync( () => fetchSettings(tier), [tier], @@ -214,11 +198,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { value: r.value, label: r.enterpriseOnly && tier !== "enterprise" - ? `${r.label} · Enterprise` + ? t("settings.workspace.regionEnterpriseSuffix", { region: r.label }) : r.label, disabled: r.enterpriseOnly && tier !== "enterprise", })); - }, [snapshot, tier]); + }, [snapshot, tier, t]); const isLoading = loading && !snapshot; @@ -227,25 +211,25 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { open={open} onClose={onClose} width="xl" - ariaLabel="Settings" + ariaLabel={t("settings.ariaLabel")} className="portal-settings" > setSection(k as SettingsSection)} - title={SECTION_LABEL[section]} + title={t(`settings.sections.${section}`)} onClose={onClose} footer={ <> - Changes apply to this workspace. + {t("settings.footerNote")} } @@ -343,6 +327,7 @@ function ProfilePanel({ onName: (v: string) => void; onEmail: (v: string) => void; }) { + const { t } = useTranslation(); if (loading) { return (
@@ -364,13 +349,13 @@ function ProfilePanel({
- {name || "Account"} + {name || t("settings.profile.accountFallback")} {role && ( {role} @@ -380,27 +365,27 @@ function ProfilePanel({ {email}
- + onName(e.target.value)} - placeholder="Your name" + placeholder={t("settings.profile.namePlaceholder")} /> onEmail(e.target.value)} - placeholder="you@company.com" + placeholder={t("settings.profile.emailPlaceholder")} />
@@ -418,19 +403,22 @@ function AppearancePanel({ theme: Theme; onTheme: (theme: Theme) => void; }) { + const { t } = useTranslation(); return (
-

Theme

+

+ {t("settings.appearance.themeTitle")} +

- Choose how the portal looks on this device. + {t("settings.appearance.themeSub")}

{THEME_OPTIONS.map((opt) => ( ))} @@ -478,13 +466,16 @@ function NotificationsPanel({ order: string[]; onToggle: (id: string, value: boolean) => void; }) { + const { t } = useTranslation(); return (
-

Email notifications

+

+ {t("settings.notifications.title")} +

- Pick which events reach your inbox. + {t("settings.notifications.sub")}

@@ -505,13 +496,14 @@ function NotificationsPanel({ {!loading && (
{order.map((id) => { - const copy = NOTIFICATION_COPY[id]; - if (!copy) return null; + if (!(NOTIFICATION_IDS as readonly string[]).includes(id)) { + return null; + } return (
- {copy.label} - {copy.description} + {t(`settings.notifications.${id}.label`)} + {t(`settings.notifications.${id}.description`)}
@@ -562,17 +555,17 @@ function WorkspacePanel({ return (
- + onWorkspaceName(e.target.value)} - placeholder="Workspace name" + placeholder={t("settings.workspace.namePlaceholder")} /> onSecurity({ sessionTimeoutMins: Number(e.target.value) }) } - options={SESSION_TIMEOUT_OPTIONS} + options={SESSION_TIMEOUT_VALUES.map((value) => ({ + value, + label: t(`settings.authentication.timeout.${value}`), + }))} />
@@ -722,6 +728,7 @@ function SessionsPanel({ loading: boolean; sessions: ActiveSession[]; }) { + const { t } = useTranslation(); if (loading) { return (
@@ -735,9 +742,11 @@ function SessionsPanel({
-

Active sessions

+

+ {t("settings.sessions.title")} +

- Devices currently signed in to this account. + {t("settings.sessions.sub")}

@@ -751,12 +760,12 @@ function SessionsPanel({
{s.current ? ( - This device + {t("settings.sessions.thisDevice")} ) : ( // TODO(backend): DELETE /v1/settings/sessions/{id} )}
@@ -784,6 +793,7 @@ function EarlyAccessPanel({ betaToggles: Record; onBeta: (id: string, value: boolean) => void; }) { + const { t } = useTranslation(); if (loading) { return (
@@ -799,9 +809,11 @@ function EarlyAccessPanel({
-

Preview features

+

+ {t("settings.earlyAccess.title")} +

- Opt into features still in preview. + {t("settings.earlyAccess.sub")}

@@ -814,7 +826,7 @@ function EarlyAccessPanel({ {f.label} {locked && ( - Enterprise + {t("settings.enterpriseBadge")} )} diff --git a/frontend/portal/src/components/Sidebar.tsx b/frontend/portal/src/components/Sidebar.tsx index a31f59d933..b1b70eae14 100644 --- a/frontend/portal/src/components/Sidebar.tsx +++ b/frontend/portal/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Dropdown, NavItem } from "@shared/components"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useTier } from "@portal/contexts/TierContext"; @@ -30,35 +31,29 @@ const EDITOR_URL = import.meta.env.DEV ? "http://localhost:5180/" : "/"; interface NavEntry { id: ViewId; - label: string; icon: React.ReactNode; } -const GROUP_PRIMARY: NavEntry[] = [ - { id: "home", label: "Home", icon: }, -]; +const GROUP_PRIMARY: NavEntry[] = [{ id: "home", icon: }]; const GROUP_OPERATIONAL: NavEntry[] = [ - { id: "users", label: "Users", icon: }, - { id: "sources", label: "Sources", icon: }, - { id: "policies", label: "Policies", icon: }, - { id: "pipelines", label: "Pipelines", icon: }, - { id: "documents", label: "Documents", icon: }, - { id: "components", label: "Components", icon: }, + { id: "users", icon: }, + { id: "sources", icon: }, + { id: "policies", icon: }, + { id: "pipelines", icon: }, + { id: "documents", icon: }, + { id: "components", icon: }, ]; const GROUP_PLATFORM: NavEntry[] = [ - { - id: "infrastructure", - label: "Infrastructure", - icon: , - }, - { id: "usage", label: "Usage & Billing", icon: }, - { id: "docs", label: "Developer Docs", icon: }, + { id: "infrastructure", icon: }, + { id: "usage", icon: }, + { id: "docs", icon: }, ]; function UsageFooter() { const { tier } = useTier(); + const { t } = useTranslation(); // Read the same endpoint Home's KPI strip uses so the doc count here can't // drift from the headline figure. The first KPI is always the doc total. const { data: kpis, loading } = useAsync( @@ -77,7 +72,9 @@ function UsageFooter() { return (
- Docs processed + + {t("shell.sidebar.docsProcessed")} + {docs ?? "—"}
@@ -105,7 +105,7 @@ function UsageFooter() { {planLabel} - {docs != null ? `${docs} docs` : "—"} + {docs != null ? t("shell.sidebar.docsCount", { docs }) : "—"}
@@ -116,13 +116,14 @@ export function Sidebar() { const { activeView, setActiveView } = useView(); const { theme } = useTheme(); const { openSettings } = useUI(); + const { t } = useTranslation(); function renderGroup(entries: NavEntry[]) { return entries.map((entry) => ( setActiveView(id as ViewId)} @@ -131,7 +132,10 @@ export function Sidebar() { } return ( -
)}
{hovered - ? `${new Date(hovered.raw.date).toLocaleDateString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - })}: ${hovered.raw.value.toLocaleString()} docs` + ? t("usageChart.srAnnounce", { + date: new Date(hovered.raw.date).toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + }), + value: hovered.raw.value.toLocaleString(), + }) : ""}
diff --git a/frontend/portal/src/components/WelcomeCarousel.tsx b/frontend/portal/src/components/WelcomeCarousel.tsx index e623200856..b485eee1a4 100644 --- a/frontend/portal/src/components/WelcomeCarousel.tsx +++ b/frontend/portal/src/components/WelcomeCarousel.tsx @@ -1,17 +1,15 @@ import { useEffect, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { Button, StatusBadge } from "@shared/components"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import "@portal/components/WelcomeCarousel.css"; type SlideAction = - | { label: string; target: ViewId } - | { label: string; action: "try-op" }; + | { labelKey: string; target: ViewId } + | { labelKey: string; action: "try-op" }; interface Slide { id: string; - eyebrow: string; - title: string; - sub: string; durationMs: number; primary: SlideAction; secondary: SlideAction; @@ -19,21 +17,22 @@ interface Slide { } function EditorOrnament() { + const { t } = useTranslation(); return (
- Critical + {t("welcome.ornament.editor.critical")}
Vulnerability Assessment Report
CVE-2026-1847 · 12 pages
- signed + {t("welcome.ornament.editor.signed")} · - OCR-clean + {t("welcome.ornament.editor.ocrClean")} · - schema match 0.97 + {t("welcome.ornament.editor.schemaMatch")}
); @@ -88,32 +87,29 @@ function AgentOrnament() { const SLIDES: Slide[] = [ { id: "editor", - eyebrow: "PDF Editor", - title: "The #1 PDF Editor on GitHub", - sub: "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane.", durationMs: 12000, - primary: { label: "Install PDF Editor", target: "editor" }, - secondary: { label: "Connect an instance", target: "editor" }, + primary: { labelKey: "welcome.slides.editor.primary", target: "editor" }, + secondary: { + labelKey: "welcome.slides.editor.secondary", + target: "editor", + }, ornament: , }, { id: "platform", - eyebrow: "Platform", - title: "PDF Infrastructure for Developers", - sub: "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls.", durationMs: 8000, - primary: { label: "Try a PDF operation", action: "try-op" }, - secondary: { label: "Get an API key", target: "infrastructure" }, + primary: { labelKey: "welcome.slides.platform.primary", action: "try-op" }, + secondary: { + labelKey: "welcome.slides.platform.secondary", + target: "infrastructure", + }, ornament: , }, { id: "agents", - eyebrow: "AI Agents", - title: "PDF Processor for AI Agents", - sub: "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship.", durationMs: 8000, - primary: { label: "Try PDF Processor", target: "sources" }, - secondary: { label: "View MCP docs", target: "docs" }, + primary: { labelKey: "welcome.slides.agents.primary", target: "sources" }, + secondary: { labelKey: "welcome.slides.agents.secondary", target: "docs" }, ornament: , }, ]; @@ -124,6 +120,7 @@ interface WelcomeCarouselProps { } export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) { + const { t } = useTranslation(); const [index, setIndex] = useState(0); const [paused, setPaused] = useState(false); const { setActiveView } = useView(); @@ -160,27 +157,33 @@ export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) { setPaused(false); } }} - aria-label="Stirling product highlights" + aria-label={t("welcome.ariaLabel")} aria-roledescription="carousel" >
-
{slide.eyebrow}
-

{slide.title}

-

{slide.sub}

+
+ {t(`welcome.slides.${slide.id}.eyebrow`)} +
+

+ {t(`welcome.slides.${slide.id}.title`)} +

+

+ {t(`welcome.slides.${slide.id}.sub`)} +

@@ -195,14 +198,17 @@ export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
{SLIDES.map((s, i) => (
} >

- Drop a sample document and we'll propose scenarios and an - extraction schema you can refine. Nothing is published until you - review it. + {t("agentBuilder.bootstrap.lead")}

diff --git a/frontend/portal/src/components/agent-builder/EvalsPanel.tsx b/frontend/portal/src/components/agent-builder/EvalsPanel.tsx index 58c601accd..651f6c1d69 100644 --- a/frontend/portal/src/components/agent-builder/EvalsPanel.tsx +++ b/frontend/portal/src/components/agent-builder/EvalsPanel.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, EmptyState, @@ -14,38 +15,50 @@ interface EvalsPanelProps { agent: Agent; } -const COLUMNS: TableColumn[] = [ - { key: "name", header: "Eval case", render: (c) => c.name }, - { - key: "result", - header: "Result", - render: (c) => - c.passing === null ? ( - not run - ) : ( - - {c.passing ? "pass" : "fail"} - - ), - }, - { - key: "latency", - header: "Latency", - align: "right", - render: (c) => ( - {c.latencyMs} ms - ), - }, -]; - /** Golden-set pass-rate, the per-case results table, and a run affordance. */ export function EvalsPanel({ agent }: EvalsPanelProps) { + const { t } = useTranslation(); + + const columns: TableColumn[] = [ + { + key: "name", + header: t("agentBuilder.evals.columnCase"), + render: (c) => c.name, + }, + { + key: "result", + header: t("agentBuilder.evals.columnResult"), + render: (c) => + c.passing === null ? ( + + {t("agentBuilder.evals.notRun")} + + ) : ( + + {c.passing + ? t("agentBuilder.evals.pass") + : t("agentBuilder.evals.fail")} + + ), + }, + { + key: "latency", + header: t("agentBuilder.evals.columnLatency"), + align: "right", + render: (c) => ( + + {t("agentBuilder.evals.latencyMs", { ms: c.latencyMs })} + + ), + }, + ]; + if (agent.evalsTotal === 0) { return (
@@ -64,34 +77,34 @@ export function EvalsPanel({ agent }: EvalsPanelProps) {
= 0.95 ? "success" : rate >= 0.8 ? "warning" : "danger"} />
- Golden-set pass rate + {t("agentBuilder.evals.goldenSetPassRate")} {Math.round(rate * 100)}%
= 0.95 ? "var(--color-green)" : "var(--color-amber)"} - label="Golden-set pass rate" + label={t("agentBuilder.evals.goldenSetPassRate")} />
- columns={COLUMNS} + columns={columns} rows={agent.evalCases} rowKey={(c) => c.id} /> diff --git a/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx b/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx index 84ef11ecb2..f355dfa4d0 100644 --- a/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx +++ b/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button, Chip, @@ -19,6 +20,7 @@ interface ScenariosPanelProps { * the submit endpoint exists. */ export function ScenariosPanel({ agent }: ScenariosPanelProps) { + const { t } = useTranslation(); // Seed from the agent and re-seed when the selection changes (key prop on the // builder forces a remount, so a plain useState initialiser is enough). const [scenarios, setScenarios] = useState(agent.scenarios); @@ -61,7 +63,9 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) { size="sm" showDot={false} > - {s.enabled ? "in eval" : "muted"} + {s.enabled + ? t("agentBuilder.scenarios.inEval") + : t("agentBuilder.scenarios.muted")}
@@ -73,7 +77,9 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) { variant="ghost" onClick={() => toggleEnabled(s.id)} > - {s.enabled ? "Mute" : "Enable"} + {s.enabled + ? t("agentBuilder.scenarios.mute") + : t("agentBuilder.scenarios.enable")} ))} @@ -81,25 +87,25 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) {
- Add scenario + {t("agentBuilder.scenarios.addScenario")}
- + setName(e.target.value)} - placeholder="e.g. Compliance escalation" + placeholder={t("agentBuilder.scenarios.namePlaceholder")} /> - + setExpectation(e.target.value)} - placeholder="What the agent should do" + placeholder={t("agentBuilder.scenarios.expectationPlaceholder")} />
diff --git a/frontend/portal/src/components/agent-builder/ToolsPanel.tsx b/frontend/portal/src/components/agent-builder/ToolsPanel.tsx index c53faa2a60..81e0f57af4 100644 --- a/frontend/portal/src/components/agent-builder/ToolsPanel.tsx +++ b/frontend/portal/src/components/agent-builder/ToolsPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Chip, ToggleSwitch } from "@shared/components"; import { type Agent, type ToolMode, TOOL_CATALOGUE } from "@portal/api/agents"; import "@portal/views/AgentBuilder.css"; @@ -14,6 +15,7 @@ interface ToolsPanelProps { * default minus an explicit deny list, picked from the known tool catalogue. */ export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) { + const { t } = useTranslation(); const [mode, setMode] = useState(agent.toolMode); const [denied, setDenied] = useState(agent.deniedTools); @@ -38,23 +40,27 @@ export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) { checked={restricted} onChange={setRestricted} disabled={!governanceUnlocked} - label="Restricted tool access" + label={t("agentBuilder.tools.restrictedAccess")} description={ governanceUnlocked - ? "Allow every tool except the ones you deny below." - : "Tool governance is available on the Enterprise plan." + ? t("agentBuilder.tools.restrictedDescription") + : t("agentBuilder.tools.governanceGate") } /> - {restricted ? "Restricted" : "Broad access"} + {restricted + ? t("agentBuilder.tools.restricted") + : t("agentBuilder.tools.broadAccess")}
{restricted && (
- Denied tools + + {t("agentBuilder.tools.deniedTools")} +

- Selected tools are blocked. Everything else stays callable. + {t("agentBuilder.tools.deniedHint")}

{TOOL_CATALOGUE.map((tool) => { diff --git a/frontend/portal/src/components/agent-builder/VersionsPanel.tsx b/frontend/portal/src/components/agent-builder/VersionsPanel.tsx index 4a9bc5377a..b05e2fa7fc 100644 --- a/frontend/portal/src/components/agent-builder/VersionsPanel.tsx +++ b/frontend/portal/src/components/agent-builder/VersionsPanel.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, StatusBadge } from "@shared/components"; import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; import "@portal/views/AgentBuilder.css"; @@ -19,6 +20,7 @@ function formatDate(iso: string): string { /** Version history with publish / rollback actions per row. */ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { + const { t } = useTranslation(); // Without governance, only the current version is meaningful to show. const versions = historyUnlocked ? agent.versions @@ -54,7 +56,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { {isCurrent && ( - current + {t("agentBuilder.versions.current")} )}
@@ -70,7 +72,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { variant="outline" onClick={() => publish(v.version)} > - Publish + {t("agentBuilder.versions.publish")} )} {v.status === "published" && !isCurrent && ( @@ -79,7 +81,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { variant="ghost" onClick={() => rollback(v.version)} > - Roll back + {t("agentBuilder.versions.rollBack")} )}
@@ -90,8 +92,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { {!historyUnlocked && publishedExists && (

- Full version history and rollback are available on the Enterprise - plan. + {t("agentBuilder.versions.historyGate")}

)}
diff --git a/frontend/portal/src/components/catalogue/ComponentCard.tsx b/frontend/portal/src/components/catalogue/ComponentCard.tsx index 6b2c46d8ab..9c662d5b9b 100644 --- a/frontend/portal/src/components/catalogue/ComponentCard.tsx +++ b/frontend/portal/src/components/catalogue/ComponentCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Card, Chip, StatusBadge } from "@shared/components"; import { type SdkComponent, @@ -19,6 +20,7 @@ export function ComponentCard({ unlocked, onOpen, }: ComponentCardProps) { + const { t } = useTranslation(); const maturity = MATURITY_META[component.maturity]; return ( @@ -28,7 +30,7 @@ export function ComponentCard({ className={"portal-components__card" + (unlocked ? "" : " is-locked")} role="button" tabIndex={0} - aria-label={`Open ${component.name} component`} + aria-label={t("catalogue.card.openAriaLabel", { name: component.name })} onClick={() => onOpen(component)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { @@ -43,7 +45,10 @@ export function ComponentCard({ {maturity.label} {!unlocked && ( - + 🔒 )} diff --git a/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx b/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx index d9f8627a38..970d2fe392 100644 --- a/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx +++ b/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Banner, Button, @@ -19,12 +20,7 @@ import "@portal/views/Components.css"; type DetailTab = "overview" | "code" | "props" | "pricing"; -const TABS: { key: DetailTab; label: string }[] = [ - { key: "overview", label: "Overview" }, - { key: "code", label: "Code" }, - { key: "props", label: "Props / API" }, - { key: "pricing", label: "Pricing" }, -]; +const TAB_KEYS: DetailTab[] = ["overview", "code", "props", "pricing"]; interface ComponentDetailModalProps { component: SdkComponent | null; @@ -43,16 +39,26 @@ export function ComponentDetailModal({ unlocked, onClose, }: ComponentDetailModalProps) { + const { t } = useTranslation(); const [tab, setTab] = useState("overview"); // Reset to the first tab whenever a new component is opened. const open = component !== null; if (!component) { return ( - + ); } + const tabs = TAB_KEYS.map((key) => ({ + key, + label: t(`catalogue.detail.tabs.${key}`), + })); + const maturity = MATURITY_META[component.maturity]; const npm = `@stirling/${component.package}`; @@ -86,7 +92,7 @@ export function ComponentDetailModal({ // publishable key scoped to this component. onClick={() => onClose()} > - Add to project + {t("catalogue.detail.addToProject")}
) : ( @@ -96,7 +102,7 @@ export function ComponentDetailModal({ // TODO(backend): route to the upgrade / contact-sales flow. onClick={() => onClose()} > - Upgrade to unlock + {t("catalogue.detail.upgradeToUnlock")} ) } @@ -104,8 +110,11 @@ export function ComponentDetailModal({ {!unlocked && ( )} @@ -113,19 +122,21 @@ export function ComponentDetailModal({
{/* TODO(backend)/host: mount the live here, booting the component against a demo document and the dev's publishable key. */} - Live preview + + {t("catalogue.detail.preview.badge")} + - Interactive sandbox renders here + {t("catalogue.detail.preview.note")}
className="portal-components__tabs" - items={TABS} + items={tabs} activeKey={tab} onChange={setTab} variant="underline" - ariaLabel="Component detail sections" + ariaLabel={t("catalogue.detail.tabsAriaLabel")} />
@@ -142,18 +153,26 @@ export function ComponentDetailModal({ ))}
- - + + 0 - ? `${component.pricing.freeQuota.toLocaleString()} / mo` - : "None" + ? t("catalogue.detail.stats.freeQuotaValue", { + amount: component.pricing.freeQuota.toLocaleString(), + }) + : t("catalogue.detail.stats.none") } />
@@ -162,11 +181,15 @@ export function ComponentDetailModal({ {tab === "code" && (
- +
)} @@ -177,23 +200,28 @@ export function ComponentDetailModal({
- + 0 - ? `${component.pricing.freeQuota.toLocaleString()} / mo` - : "None" + ? t("catalogue.detail.stats.freeQuotaValue", { + amount: component.pricing.freeQuota.toLocaleString(), + }) + : t("catalogue.detail.stats.none") } />

- Metered per {component.pricing.unit}. Usage beyond the monthly - free quota is billed to your account and itemised under Usage - & Billing. + {t("catalogue.detail.pricing.note", { + unit: component.pricing.unit, + })}

)} diff --git a/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx b/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx index 03f8e46925..0bb77cb47a 100644 --- a/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx +++ b/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Chip, Table, type TableColumn } from "@shared/components"; import type { ComponentProp } from "@portal/api/sdkComponents"; import "@portal/views/Components.css"; @@ -9,43 +10,46 @@ interface ComponentPropsTableProps { /** Small Props/API reference shown under the detail modal's Props tab. */ export function ComponentPropsTable({ props: rows }: ComponentPropsTableProps) { + const { t } = useTranslation(); const columns = useMemo[]>( () => [ { key: "name", - header: "Prop", + header: t("catalogue.props.columns.name"), render: (p) => ( {p.name} ), }, { key: "type", - header: "Type", + header: t("catalogue.props.columns.type"), render: (p) => ( {p.type} ), }, { key: "required", - header: "Required", + header: t("catalogue.props.columns.required"), render: (p) => p.required ? ( - required + {t("catalogue.props.required")} ) : ( - optional + + {t("catalogue.props.optional")} + ), }, { key: "description", - header: "Description", + header: t("catalogue.props.columns.description"), render: (p) => ( {p.description} ), }, ], - [], + [t], ); return ( diff --git a/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx b/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx index 8cad2c51ae..ec46ad855f 100644 --- a/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx +++ b/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { MetricCard, MetricStrip } from "@shared/components"; import type { ComponentsResponse } from "@portal/api/sdkComponents"; @@ -6,11 +7,11 @@ import type { ComponentsResponse } from "@portal/api/sdkComponents"; * so the strip's structure stays stable across loading / empty / ready states. * Only values flow from the API. */ -const KPI_LABELS = [ - "Components GA", - "In beta", - "Embeds this month", - "Component spend (MTD)", +const KPI_LABEL_KEYS = [ + "catalogue.summary.componentsGa", + "catalogue.summary.inBeta", + "catalogue.summary.embedsThisMonth", + "catalogue.summary.componentSpendMtd", ] as const; interface ComponentsSummaryStripProps { @@ -22,6 +23,7 @@ export function ComponentsSummaryStrip({ data, loading, }: ComponentsSummaryStripProps) { + const { t } = useTranslation(); const s = loading ? undefined : data?.summary; const values: (string | number)[] = [ s?.gaCount ?? "—", @@ -32,8 +34,8 @@ export function ComponentsSummaryStrip({ return ( - {KPI_LABELS.map((label, i) => ( - + {KPI_LABEL_KEYS.map((labelKey, i) => ( + ))} ); diff --git a/frontend/portal/src/components/docs/AuthenticationSection.tsx b/frontend/portal/src/components/docs/AuthenticationSection.tsx index b6f782305c..65b1d89599 100644 --- a/frontend/portal/src/components/docs/AuthenticationSection.tsx +++ b/frontend/portal/src/components/docs/AuthenticationSection.tsx @@ -1,17 +1,19 @@ +import { useTranslation } from "react-i18next"; import { Chip, CodeBlock } from "@shared/components"; import { DocsSection } from "@portal/components/docs/DocsSection"; export function AuthenticationSection() { + const { t } = useTranslation(); return (
@@ -19,13 +21,13 @@ export function AuthenticationSection() { sk_live_ - Production keys — billed, rate-limited per your plan. + {t("docs.authentication.liveKey")}
sk_test_ - Sandbox keys — free, return synthetic fixtures. + {t("docs.authentication.testKey")}
diff --git a/frontend/portal/src/components/docs/ComponentsSection.tsx b/frontend/portal/src/components/docs/ComponentsSection.tsx index b25f5e5164..10a2349154 100644 --- a/frontend/portal/src/components/docs/ComponentsSection.tsx +++ b/frontend/portal/src/components/docs/ComponentsSection.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Card, Chip, CodeBlock } from "@shared/components"; import type { EmbedComponent } from "@portal/api/docs"; import { DocsSection } from "@portal/components/docs/DocsSection"; @@ -7,12 +8,13 @@ export function ComponentsSection({ }: { components: EmbedComponent[]; }) { + const { t } = useTranslation(); return (
{components.map((c) => ( @@ -29,7 +31,7 @@ export function ComponentsSection({
void; }) { + const { t } = useTranslation(); return ( -