From 869e0d8db9e85d93f526662fac0f06ac5939ea23 Mon Sep 17 00:00:00 2001 From: mroops0111 Date: Thu, 6 Aug 2026 10:37:37 +0800 Subject: [PATCH 1/3] feat(i18n): Traditional Chinese localization for Studio and ontology End-to-end internationalization with English and Traditional Chinese. Shared core: Locale, SUPPORTED_LOCALES, LocalizedText, and localize live in @braidhq/schema, so every package draws locales from one list. Studio UI: react-i18next with i18next-icu, per-area catalogs under locales/{en,zh-Hant}/, ICU plurals, a locale-aware date and relative-time formatter, and a language dropdown in Appearance settings. Browser-locale auto-detect and dynamic html lang. Ontology labels: the descriptor label becomes LocalizedText, and the DDD plugin declares its zh-Hant labels in a locales/ folder. Structural DDD jargon stays English (Bounded Context, Aggregate, Command, Domain Event), generic terms and source roles translate (Actor, Business Rule, Intent, Code). Studio resolves labels by the active locale, and node-type badges, the filter, and the legend now render the label instead of the raw id. CJK font: bundle Noto Sans TC and preload the catalog glyphs before first paint, so Chinese chrome never swaps font. Also normalize i18n key names to one i18next-standard convention, with Title, Description, Button, and Tooltip suffixes and no abbreviations. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/src/domain/plugin/OntologyPlugin.ts | 8 +- .../ontology-ddd/src/DDDOntologyPlugin.ts | 64 ++--- .../ontology-ddd/src/locales/en/labels.ts | 40 ++++ .../src/locales/zh-Hant/labels.ts | 18 ++ packages/schema/src/index.ts | 1 + packages/schema/src/locale.ts | 44 ++++ packages/schema/src/ontology.ts | 7 +- packages/sdk/src/defineOntologyPlugin.ts | 3 +- packages/studio/package.json | 4 + packages/studio/src/App.tsx | 28 ++- .../studio/src/components/ActionInputForm.tsx | 55 +++-- .../studio/src/components/AddSourceDialog.tsx | 73 +++--- .../src/components/BatchInFlightBanner.tsx | 43 +--- .../studio/src/components/CommandPalette.tsx | 46 ++-- .../src/components/CreateWorkspaceWizard.tsx | 175 +++++++------- .../src/components/InFlightRunBanner.tsx | 6 +- .../src/components/MultiSelectDropdown.tsx | 26 ++- .../studio/src/components/ReactorBanner.tsx | 22 +- packages/studio/src/components/Sidebar.tsx | 71 +++--- .../components/SkillTranscript/Mermaid.tsx | 4 +- .../SkillTranscript/ToolCallRow.tsx | 10 +- .../components/SkillTranscript/ToolGroup.tsx | 10 +- .../src/components/SkillTranscript/index.tsx | 48 ++-- .../studio/src/components/SubmitIssueForm.tsx | 49 ++-- packages/studio/src/components/UserPicker.tsx | 19 +- .../src/components/WorkspaceDetailsSheet.tsx | 144 +++++++----- .../components/WorkspaceSkillPermissions.tsx | 39 ++-- .../src/components/graph/EdgeDetailPanel.tsx | 18 +- .../src/components/graph/GraphCanvas.tsx | 23 +- .../src/components/graph/GraphNavigator.tsx | 127 +++++----- .../src/components/graph/GraphNodeCard.tsx | 4 +- .../src/components/graph/GraphToolbar.tsx | 18 +- .../src/components/graph/NodeDetailPanel.tsx | 21 +- .../src/components/graph/NodeTypeBadge.tsx | 2 +- .../src/components/graph/ontologyPalette.ts | 18 +- .../studio/src/components/graph/usePalette.ts | 7 +- packages/studio/src/components/ui/dialog.tsx | 7 +- packages/studio/src/components/ui/sheet.tsx | 4 +- packages/studio/src/lib/api.ts | 2 +- packages/studio/src/lib/i18n/config.ts | 31 +++ packages/studio/src/lib/i18n/datetime.ts | 63 +++++ packages/studio/src/lib/i18n/i18next.d.ts | 11 + packages/studio/src/lib/i18n/index.ts | 5 + packages/studio/src/lib/i18n/locale.ts | 47 ++++ packages/studio/src/lib/i18n/useLocale.ts | 19 ++ packages/studio/src/lib/sourceDraft.ts | 11 +- packages/studio/src/lib/useWorkspaceEvents.ts | 2 +- packages/studio/src/locales/en/actionInput.ts | 17 ++ packages/studio/src/locales/en/admin.ts | 63 +++++ packages/studio/src/locales/en/common.ts | 52 +++++ packages/studio/src/locales/en/graph.ts | 63 +++++ packages/studio/src/locales/en/history.ts | 104 +++++++++ packages/studio/src/locales/en/index.ts | 27 +++ packages/studio/src/locales/en/review.ts | 220 ++++++++++++++++++ packages/studio/src/locales/en/settings.ts | 11 + packages/studio/src/locales/en/shell.ts | 82 +++++++ packages/studio/src/locales/en/sources.ts | 59 +++++ packages/studio/src/locales/en/transcript.ts | 36 +++ packages/studio/src/locales/en/workspace.ts | 136 +++++++++++ .../studio/src/locales/zh-Hant/actionInput.ts | 19 ++ packages/studio/src/locales/zh-Hant/admin.ts | 65 ++++++ packages/studio/src/locales/zh-Hant/common.ts | 51 ++++ packages/studio/src/locales/zh-Hant/graph.ts | 64 +++++ .../studio/src/locales/zh-Hant/history.ts | 103 ++++++++ packages/studio/src/locales/zh-Hant/index.ts | 28 +++ packages/studio/src/locales/zh-Hant/review.ts | 218 +++++++++++++++++ .../studio/src/locales/zh-Hant/settings.ts | 13 ++ packages/studio/src/locales/zh-Hant/shell.ts | 74 ++++++ .../studio/src/locales/zh-Hant/sources.ts | 61 +++++ .../studio/src/locales/zh-Hant/transcript.ts | 31 +++ .../studio/src/locales/zh-Hant/workspace.ts | 129 ++++++++++ packages/studio/src/main.tsx | 30 ++- packages/studio/src/pages/Actions.tsx | 78 ++++--- packages/studio/src/pages/Activity.tsx | 81 +++---- packages/studio/src/pages/Batch.tsx | 126 +++++----- packages/studio/src/pages/Clarification.tsx | 131 +++++------ packages/studio/src/pages/GraphTable.tsx | 36 +-- packages/studio/src/pages/History.tsx | 183 +++++++-------- packages/studio/src/pages/Login.tsx | 14 +- packages/studio/src/pages/Proposals.tsx | 115 +++++---- packages/studio/src/pages/Settings.tsx | 12 +- .../studio/src/pages/settings/AboutTab.tsx | 14 +- .../src/pages/settings/AppearanceTab.tsx | 44 +++- .../studio/src/pages/settings/ServersTab.tsx | 54 ++--- .../studio/src/pages/settings/UsersTab.tsx | 82 +++---- packages/studio/src/styles.css | 4 +- packages/studio/test/lib/i18n/locale.test.ts | 87 +++++++ packages/studio/test/lib/sourceDraft.test.ts | 18 +- pnpm-lock.yaml | 101 ++++++++ 89 files changed, 3376 insertions(+), 1057 deletions(-) create mode 100644 packages/ontology-ddd/src/locales/en/labels.ts create mode 100644 packages/ontology-ddd/src/locales/zh-Hant/labels.ts create mode 100644 packages/schema/src/locale.ts create mode 100644 packages/studio/src/lib/i18n/config.ts create mode 100644 packages/studio/src/lib/i18n/datetime.ts create mode 100644 packages/studio/src/lib/i18n/i18next.d.ts create mode 100644 packages/studio/src/lib/i18n/index.ts create mode 100644 packages/studio/src/lib/i18n/locale.ts create mode 100644 packages/studio/src/lib/i18n/useLocale.ts create mode 100644 packages/studio/src/locales/en/actionInput.ts create mode 100644 packages/studio/src/locales/en/admin.ts create mode 100644 packages/studio/src/locales/en/common.ts create mode 100644 packages/studio/src/locales/en/graph.ts create mode 100644 packages/studio/src/locales/en/history.ts create mode 100644 packages/studio/src/locales/en/index.ts create mode 100644 packages/studio/src/locales/en/review.ts create mode 100644 packages/studio/src/locales/en/settings.ts create mode 100644 packages/studio/src/locales/en/shell.ts create mode 100644 packages/studio/src/locales/en/sources.ts create mode 100644 packages/studio/src/locales/en/transcript.ts create mode 100644 packages/studio/src/locales/en/workspace.ts create mode 100644 packages/studio/src/locales/zh-Hant/actionInput.ts create mode 100644 packages/studio/src/locales/zh-Hant/admin.ts create mode 100644 packages/studio/src/locales/zh-Hant/common.ts create mode 100644 packages/studio/src/locales/zh-Hant/graph.ts create mode 100644 packages/studio/src/locales/zh-Hant/history.ts create mode 100644 packages/studio/src/locales/zh-Hant/index.ts create mode 100644 packages/studio/src/locales/zh-Hant/review.ts create mode 100644 packages/studio/src/locales/zh-Hant/settings.ts create mode 100644 packages/studio/src/locales/zh-Hant/shell.ts create mode 100644 packages/studio/src/locales/zh-Hant/sources.ts create mode 100644 packages/studio/src/locales/zh-Hant/transcript.ts create mode 100644 packages/studio/src/locales/zh-Hant/workspace.ts create mode 100644 packages/studio/test/lib/i18n/locale.test.ts diff --git a/packages/core/src/domain/plugin/OntologyPlugin.ts b/packages/core/src/domain/plugin/OntologyPlugin.ts index 2fe21e6e..42f80c15 100644 --- a/packages/core/src/domain/plugin/OntologyPlugin.ts +++ b/packages/core/src/domain/plugin/OntologyPlugin.ts @@ -1,4 +1,4 @@ -import type { BatchUnit, EdgeTypeId, ModelSnapshot, NodeStatus, NodeTypeId, OntologyId, SkillId, SourceRole, ValidationIssue } from '@braidhq/schema' +import type { BatchUnit, EdgeTypeId, LocalizedText, ModelSnapshot, NodeStatus, NodeTypeId, OntologyId, SkillId, SourceRole, ValidationIssue } from '@braidhq/schema' import type { Plugin } from './Plugin.js' /** @@ -8,7 +8,7 @@ import type { Plugin } from './Plugin.js' */ export interface SourceRoleDescriptor { readonly id: SourceRole - readonly label: string + readonly label: LocalizedText /** Sources of this role must be present for the ontology to run. */ readonly required?: boolean /** Sources of this role enumerate into batch units, and their sync drives the Reactor. */ @@ -19,7 +19,7 @@ export interface SourceRoleDescriptor { export interface NodeTypeDescriptor { readonly id: NodeTypeId - readonly label: string + readonly label: LocalizedText readonly description?: string readonly allowedStatuses?: readonly NodeStatus[] /** @@ -57,7 +57,7 @@ export interface NodeTypeDescriptor { export interface EdgeTypeDescriptor { readonly id: EdgeTypeId - readonly label?: string + readonly label?: LocalizedText /** * Short prose explaining what this edge means and when to emit it. * Surfaced to LLMs through the `/ontology` API, diff --git a/packages/ontology-ddd/src/DDDOntologyPlugin.ts b/packages/ontology-ddd/src/DDDOntologyPlugin.ts index 204cacfc..c276f431 100644 --- a/packages/ontology-ddd/src/DDDOntologyPlugin.ts +++ b/packages/ontology-ddd/src/DDDOntologyPlugin.ts @@ -1,5 +1,30 @@ +import type { EdgeTypeDescriptor, NodeTypeDescriptor } from '@braidhq/core' +import type { LocalizedText } from '@braidhq/schema' +import type { SourceRoleInput } from '@braidhq/sdk' import { EdgeTypeId, NodeTypeId, SkillId } from '@braidhq/schema' import { defineOntologyPlugin } from '@braidhq/sdk' +import enLabels from './locales/en/labels.js' +import zhHantLabels from './locales/zh-Hant/labels.js' + +type LabelKind = keyof typeof enLabels + +/** Build a descriptor label from the per-locale label files, keyed by id. */ +function localeLabel(kind: LabelKind, id: string): LocalizedText { + const en = (enLabels[kind] as Record)[id] ?? id + const zhHant = (zhHantLabels[kind] as Record)[id] + return zhHant ? { 'en': en, 'zh-Hant': zhHant } : en +} + +// Attach labels from the locales folder to label-less descriptors, keyed by id. +function localeRoles(roles: readonly Omit[]): SourceRoleInput[] { + return roles.map(role => ({ ...role, label: localeLabel('sourceRoles', role.id) })) +} +function localeNodes(nodes: readonly Omit[]): NodeTypeDescriptor[] { + return nodes.map(node => ({ ...node, label: localeLabel('nodeTypes', node.id) })) +} +function localeEdges(edges: readonly Omit[]): EdgeTypeDescriptor[] { + return edges.map(edge => ({ ...edge, label: localeLabel('edgeTypes', edge.id) })) +} /** * The default DDD ontology, consolidating Strategic DDD (Evans), @@ -21,10 +46,10 @@ export const dddOntology = defineOntologyPlugin({ // this value prop fails. Intent docs are the unit-bearing role, each doc // is one extraction unit and its sync drives the Reactor. Code is context // the per-unit skill reads, and seeds derived mode when no intent exists. - sourceRoles: [ - { id: 'intent', label: 'Intent', required: true, unitBearing: true, pathSegment: 'intents' }, - { id: 'code', label: 'Code', required: true, pathSegment: 'codebases' }, - ], + sourceRoles: localeRoles([ + { id: 'intent', required: true, unitBearing: true, pathSegment: 'intents' }, + { id: 'code', required: true, pathSegment: 'codebases' }, + ]), // SKILL.md prompts shipped with this ontology. // They encode DDD-specific reasoning, like the Context Mapping edges, @@ -48,10 +73,9 @@ export const dddOntology = defineOntologyPlugin({ }, ], - nodeTypes: [ + nodeTypes: localeNodes([ { id: NodeTypeId.parse('boundedContext'), - label: 'Bounded Context', description: 'A subsystem with its own ubiquitous language; everything inside is one consistency boundary. Strategic DDD primitive (Evans Blue Book Part IV; Khononov 2021 ch. 3).', color: 'oklch(0.7 0.035 260)', defaultVisible: true, @@ -59,7 +83,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: NodeTypeId.parse('aggregate'), - label: 'Aggregate', description: 'Cluster of domain objects treated as a unit for data changes; has a single root entity that controls access. Tactical DDD primitive (Evans Blue Book Part II; Vernon IDDD ch. 10; Khononov 2021 ch. 6).', color: 'oklch(0.7 0.12 155)', defaultVisible: true, @@ -67,52 +90,45 @@ export const dddOntology = defineOntologyPlugin({ }, { id: NodeTypeId.parse('command'), - label: 'Command', description: 'Imperative request that asks the system to change state; names use verbs (placeOrder, cancelOrder). CQRS primitive (Young 2010; Khononov 2021 ch. 11). The blue sticky in EventStorming.', color: 'oklch(0.65 0.14 250)', renderHint: { expandedUnder: NodeTypeId.parse('aggregate') }, }, { id: NodeTypeId.parse('query'), - label: 'Query', description: 'Read-only request that returns state without modifying it. CQRS primitive (Young 2010; Khononov 2021 ch. 11). Strict CQRS routes queries to a dedicated read-model node; in the absence of that node type, queries attach to the aggregate.', color: 'oklch(0.7 0.11 220)', renderHint: { expandedUnder: NodeTypeId.parse('aggregate') }, }, { id: NodeTypeId.parse('event'), - label: 'Domain Event', description: 'Past-tense fact about something that has already happened (OrderPlaced, ItemAdded). Tactical DDD primitive (Vernon IDDD ch. 8) and the orange sticky in EventStorming.', color: 'oklch(0.76 0.13 80)', renderHint: { expandedUnder: NodeTypeId.parse('command') }, }, { id: NodeTypeId.parse('rule'), - label: 'Business Rule', description: 'Invariant that must hold (MaxItemsRule, PositiveQuantityRule). Tactical DDD (Evans Specification pattern; Vernon IDDD invariants). Per-operation rules attach to a command or query; aggregate-wide invariants attach to the aggregate itself.', color: 'oklch(0.65 0.15 20)', renderHint: { expandedUnder: NodeTypeId.parse('command') }, }, { id: NodeTypeId.parse('actor'), - label: 'Actor', description: 'External role that triggers a command or query (Customer, Admin, BillingService). EventStorming primitive (Brandolini; the yellow stick-figure sticky) and Khononov 2021. Not in strict Evans / Vernon canon, where the issuer lives on the command\'s metadata.', color: 'oklch(0.72 0.11 310)', renderHint: { section: 'Actors' }, }, { id: NodeTypeId.parse('policy'), - label: 'Policy', description: 'Automatic reaction: "when event X happens, do Y". EventStorming primitive (Brandolini; the purple sticky) and Khononov 2021. Materialises Vernon\'s Process Manager / Saga pattern when the reaction crosses aggregates or has its own naming.', color: 'oklch(0.62 0.15 310)', renderHint: { section: 'Reactions' }, }, - ], + ]), - edgeTypes: [ + edgeTypes: localeEdges([ { id: EdgeTypeId.parse('contains'), - label: 'contains', description: 'A BoundedContext contains aggregates. Commands, queries, events, and rules belong to an aggregate and are reached via accepts, emits, or constrainedBy. Strategic DDD (Evans Blue Book Part IV).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('aggregate')], @@ -121,7 +137,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('accepts'), - label: 'accepts', description: 'Aggregate is the entry point for operations against its state; commands modify the aggregate and queries read it. Tactical DDD with CQRS (Khononov 2021 ch. 11). Strict CQRS would route queries to a dedicated read-model node; this ontology routes both through the aggregate.', fromTypes: [NodeTypeId.parse('aggregate')], toTypes: [NodeTypeId.parse('command'), NodeTypeId.parse('query')], @@ -130,7 +145,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('emits'), - label: 'emits', description: 'A command produces an event as the result of executing on its aggregate. Either source is valid in this ontology: command-source (CQRS / EventStorming visual reading: cmd to evt) and aggregate-source (Vernon IDDD structural reading: agg to evt). Khononov 2021 illustrates both; prefer the command-source form when extracting from PRD/spec language and the aggregate-source form when describing state ownership.', fromTypes: [NodeTypeId.parse('command'), NodeTypeId.parse('aggregate')], toTypes: [NodeTypeId.parse('event')], @@ -139,7 +153,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('triggers'), - label: 'triggers', description: 'Process-manager / saga flow: an event drives a downstream command or policy, often in a different aggregate. EventStorming flow notation (Brandolini); CQRS saga pattern (Khononov 2021 ch. 11).', fromTypes: [NodeTypeId.parse('event')], toTypes: [NodeTypeId.parse('command'), NodeTypeId.parse('policy')], @@ -148,7 +161,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('enacts'), - label: 'enacts', description: 'A policy issues a command as its reaction. The "do Y" half of EventStorming\'s "when X then Y" / Vernon\'s Process Manager (Brandolini; Vernon IDDD ch. 13; Khononov 2021).', fromTypes: [NodeTypeId.parse('policy')], toTypes: [NodeTypeId.parse('command')], @@ -157,7 +169,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('constrainedBy'), - label: 'constrained by', description: 'Per-operation rule: a command or query is constrained by a specific rule. Aggregate-wide invariant: an aggregate is constrained by a rule that must hold across all of its operations. Tactical DDD (Evans Specification pattern; Vernon IDDD invariants).', fromTypes: [NodeTypeId.parse('command'), NodeTypeId.parse('query'), NodeTypeId.parse('aggregate')], toTypes: [NodeTypeId.parse('rule')], @@ -166,7 +177,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('dependsOn'), - label: 'depends on', description: 'Aggregates reference other aggregates by id only. Tactical DDD (Vernon IDDD "Reference Other Aggregates by Identity" rule; Khononov 2021 ch. 6). Cross-aggregate command or query coupling is expressed through triggers rather than direct references.', fromTypes: [NodeTypeId.parse('aggregate')], toTypes: [NodeTypeId.parse('aggregate')], @@ -175,7 +185,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('performedBy'), - label: 'performed by', description: 'A command or query is triggered by an actor. EventStorming convention (Brandolini) and Khononov 2021. Not in strict Evans / Vernon canon, where the issuer lives on the command\'s metadata rather than as a graph edge.', fromTypes: [NodeTypeId.parse('command'), NodeTypeId.parse('query')], toTypes: [NodeTypeId.parse('actor')], @@ -190,7 +199,6 @@ export const dddOntology = defineOntologyPlugin({ // not derivable from individual feature slices. { id: EdgeTypeId.parse('partnership'), - label: 'partnership', description: 'Symmetric: two BoundedContexts are committed to succeed or fail together; coordinated planning and joint releases. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], @@ -199,7 +207,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('customerSupplier'), - label: 'customer-supplier', description: 'Asymmetric (customer downstream, supplier upstream): the customer BoundedContext depends on the supplier and has political pull to ask for changes. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], @@ -208,7 +215,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('conformist'), - label: 'conformist', description: 'Asymmetric (conformist downstream, upstream uncooperative): the downstream BoundedContext depends on an upstream it has no political pull over and adopts the upstream\'s model as-is. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], @@ -217,7 +223,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('sharedKernel'), - label: 'shared kernel', description: 'Symmetric: two BoundedContexts intentionally share a small piece of model (often a value object). Any change to the shared part requires coordination between both teams. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], @@ -226,7 +231,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('anticorruptionLayer'), - label: 'anticorruption layer', description: 'Asymmetric (acl-owner downstream, upstream isolated from): the downstream BoundedContext isolates itself from the upstream by building a translation layer so its internal model is not corrupted by the upstream\'s shape. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], @@ -235,7 +239,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('openHostService'), - label: 'open host service', description: 'Asymmetric (host upstream, consumer downstream): the upstream BoundedContext offers a well-defined open protocol any downstream can consume without bespoke negotiation. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], @@ -244,14 +247,13 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('publishedLanguage'), - label: 'published language', description: 'Asymmetric (publisher upstream, consumer downstream): the upstream BoundedContext publishes a documented schema or format; downstreams consume it as-is. Often combined with openHostService. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], cardinality: '1:N', color: 'oklch(0.7 0.15 100)', }, - ], + ]), // Batch and reactor binding. // The per-unit skill is ddd:extract. diff --git a/packages/ontology-ddd/src/locales/en/labels.ts b/packages/ontology-ddd/src/locales/en/labels.ts new file mode 100644 index 00000000..1e9c81e2 --- /dev/null +++ b/packages/ontology-ddd/src/locales/en/labels.ts @@ -0,0 +1,40 @@ +/** + * English labels for the DDD ontology vocabulary, keyed by id. + * The base locale. zh-Hant lives alongside in ../zh-Hant/labels. + * Descriptions carry academic citations and stay in DDDOntologyPlugin. + */ +export const labels = { + sourceRoles: { + intent: 'Intent', + code: 'Code', + }, + nodeTypes: { + boundedContext: 'Bounded Context', + aggregate: 'Aggregate', + command: 'Command', + query: 'Query', + event: 'Domain Event', + rule: 'Business Rule', + actor: 'Actor', + policy: 'Policy', + }, + edgeTypes: { + contains: 'contains', + accepts: 'accepts', + emits: 'emits', + triggers: 'triggers', + enacts: 'enacts', + constrainedBy: 'constrained by', + dependsOn: 'depends on', + performedBy: 'performed by', + partnership: 'partnership', + customerSupplier: 'customer-supplier', + conformist: 'conformist', + sharedKernel: 'shared kernel', + anticorruptionLayer: 'anticorruption layer', + openHostService: 'open host service', + publishedLanguage: 'published language', + }, +} as const + +export default labels diff --git a/packages/ontology-ddd/src/locales/zh-Hant/labels.ts b/packages/ontology-ddd/src/locales/zh-Hant/labels.ts new file mode 100644 index 00000000..c4ee7267 --- /dev/null +++ b/packages/ontology-ddd/src/locales/zh-Hant/labels.ts @@ -0,0 +1,18 @@ +/** + * Traditional Chinese labels for the DDD ontology vocabulary, keyed by id. + * Node and edge types stay English, the DDD ubiquitous language. + * Only source roles, shown in the source wizard, translate. + * A missing id falls back to the English label. + */ +export const labels = { + sourceRoles: { + intent: '意圖', + code: '程式碼', + }, + // Node and edge types stay fully English, the DDD ubiquitous language. + // One graph must not mix Chinese and English type badges. + nodeTypes: {}, + edgeTypes: {}, +} as const + +export default labels diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index ed88304c..2b3c3527 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -10,6 +10,7 @@ export * from './error.js' export * from './event.js' export * from './graph-validation.js' export * from './history.js' +export * from './locale.js' export * from './mcp.js' export * from './model.js' export * from './ontology.js' diff --git a/packages/schema/src/locale.ts b/packages/schema/src/locale.ts new file mode 100644 index 00000000..81485c50 --- /dev/null +++ b/packages/schema/src/locale.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +/** + * Locales the product ships translations for. Closed set, so an enum. + * The Studio UI locale and plugin-declared localized text draw from it. + */ +export const Locale = z.enum(['en', 'zh-Hant']) +export type Locale = z.infer + +export const FALLBACK_LOCALE: Locale = 'en' + +/** A supported locale plus its own endonym, shown in a language picker. */ +export interface LocaleOption { + code: Locale + label: string +} + +export const SUPPORTED_LOCALES = [ + { code: 'en', label: 'English' }, + { code: 'zh-Hant', label: '繁體中文' }, +] as const satisfies readonly LocaleOption[] + +export function isLocale(value: unknown): value is Locale { + return Locale.safeParse(value).success +} + +/** + * Display text with optional per-locale variants. + * A bare string applies to all locales. + * A partial map translates only the terms a plugin wants. + */ +export type LocalizedText = string | Partial> + +/** Build a LocalizedText zod schema, reusing the same constraints for each variant. */ +export function localizedText(value: z.ZodString = z.string()): z.ZodType { + return z.union([value, z.partialRecord(Locale, value)]) +} + +/** Resolve localized text for a locale, falling back when a variant is missing. */ +export function localize(text: LocalizedText, locale: Locale, fallback: Locale = FALLBACK_LOCALE): string { + if (typeof text === 'string') + return text + return text[locale] ?? text[fallback] ?? Object.values(text)[0] ?? '' +} diff --git a/packages/schema/src/ontology.ts b/packages/schema/src/ontology.ts index 5a96d7b2..179a217f 100644 --- a/packages/schema/src/ontology.ts +++ b/packages/schema/src/ontology.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { OntologyId } from './common.js' +import { localizedText } from './locale.js' import { SourceRole } from './source.js' export const NodeTypeId = z.string().min(1).brand<'NodeTypeId'>() @@ -21,7 +22,7 @@ export type NodeTypeRenderHint = z.infer export const NodeTypeDescriptor = z.object({ id: NodeTypeId, - label: z.string().min(1).max(40), + label: localizedText(z.string().min(1).max(40)), description: z.string().optional(), allowedStatuses: z.array(NodeStatus).optional(), color: z.string().optional(), @@ -35,7 +36,7 @@ export type EdgeCardinality = z.infer export const EdgeTypeDescriptor = z.object({ id: EdgeTypeId, - label: z.string().min(1).max(40), + label: localizedText(z.string().min(1).max(40)), description: z.string().optional(), fromTypes: z.array(NodeTypeId), toTypes: z.array(NodeTypeId), @@ -50,7 +51,7 @@ export type EdgeTypeDescriptor = z.infer */ export const SourceRoleDescriptor = z.object({ id: SourceRole, - label: z.string().min(1), + label: localizedText(z.string().min(1)), // Sources of this role must be present for the ontology to run. required: z.boolean().optional(), // Sources of this role enumerate into batch units, and their sync drives the Reactor. diff --git a/packages/sdk/src/defineOntologyPlugin.ts b/packages/sdk/src/defineOntologyPlugin.ts index a4cc6159..f981ead1 100644 --- a/packages/sdk/src/defineOntologyPlugin.ts +++ b/packages/sdk/src/defineOntologyPlugin.ts @@ -7,6 +7,7 @@ import type { } from '@braidhq/core' import type { EdgeTypeId, + LocalizedText, NodeTypeId, OntologyId, PluginId, @@ -26,7 +27,7 @@ import { /** Declarative source role an ontology contributes. `id` is branded on build. */ export interface SourceRoleInput { readonly id: string - readonly label: string + readonly label: LocalizedText readonly required?: boolean readonly unitBearing?: boolean readonly pathSegment?: string diff --git a/packages/studio/package.json b/packages/studio/package.json index d9bfbdf6..1489f670 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -23,6 +23,7 @@ "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", + "@fontsource-variable/noto-sans-tc": "^5.3.0", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-label": "^2.1.15", "@radix-ui/react-slot": "^1.3.3", @@ -33,11 +34,14 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "cmdk": "^1.1.1", + "i18next": "^26.3.6", + "i18next-icu": "^2.4.4", "lucide-react": "^1.28.0", "mermaid": "^11.16.0", "radix-ui": "^1.6.7", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0", diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index ad31ea28..8023b60d 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -2,6 +2,7 @@ import type { EdgeId, NodeId, ProposalId } from '@braidhq/schema' import type { Surface } from './components/CommandPalette' import { Settings2, Sparkles } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { BatchInFlightBanner } from './components/BatchInFlightBanner' import { CommandPalette } from './components/CommandPalette' import { CreateWorkspaceWizard } from './components/CreateWorkspaceWizard' @@ -39,9 +40,10 @@ export function App() { } function BootScreen() { + const { t } = useTranslation() return (
- Loading… + {t('common.loading')}
) } @@ -269,17 +271,18 @@ function WorkspaceHeader({ workspaceId, activeSurface, onOpenDetails }: { activeSurface: Surface | null onOpenDetails: () => void }) { + const { t } = useTranslation() // Surface nav lives in the Sidebar's HERE section now. The header reports where you are, // workspace name plus optional surface, and hosts page-specific tools on the right. const surfaceLabel = activeSurface === 'actions' - ? 'Actions' + ? t('shell.surfaces.actions') : activeSurface === 'clarifications' - ? 'Clarifications' + ? t('shell.surfaces.clarifications') : activeSurface === 'proposals' - ? 'Proposals' + ? t('shell.surfaces.proposals') : activeSurface === 'history' - ? 'History' + ? t('shell.surfaces.history') : null return ( @@ -291,7 +294,7 @@ function WorkspaceHeader({ workspaceId, activeSurface, onOpenDetails }: { @@ -273,6 +278,7 @@ function useSourceBadges( providerKind: string, rawOptions: readonly SkillInputDynamicOption[], ): readonly DynamicOptionWithBadge[] { + const { t, i18n } = useTranslation() const isSourceProvider = providerKind === 'source' const sourceIds = useMemo(() => { if (!isSourceProvider) @@ -332,9 +338,9 @@ function useSourceBadges( return { ...opt, badge: { - text: relativeAgo(now, lastAt), + text: relativeAgo(now, lastAt, t), tone: 'fresh' as const, - title: `Last extracted ${new Date(lastAt).toLocaleString()}`, + title: t('actionInput.lastProcessed', { date: formatDateTimeIn(i18n.language, lastAt) }), }, } } @@ -342,17 +348,17 @@ function useSourceBadges( return { ...opt, badge: { - text: 'stale', + text: t('actionInput.staleBadge'), tone: 'stale' as const, title: lastAt - ? `Changed since last extract ${new Date(lastAt).toLocaleString()}` - : 'Changed since last extract', + ? t('actionInput.changedSinceWithDate', { date: formatDateTimeIn(i18n.language, lastAt) }) + : t('actionInput.changedSinceLabel'), }, } } return opt }) - }, [isSourceProvider, rawOptions, sourceIds, diffQueries, ledgerQueries]) + }, [isSourceProvider, rawOptions, sourceIds, diffQueries, ledgerQueries, t, i18n.language]) } interface DynamicOptionWithBadge extends SkillInputDynamicOption { @@ -364,24 +370,25 @@ interface DynamicOptionWithBadge extends SkillInputDynamicOption { } /** - * Compact "Nm ago", "Nh ago", or "Nd ago" formatter for the freshness chip. - * Avoids pulling in a date library for one badge. + * Compact freshness chip label. + * Keeps the terse "Nm ago" form rather than the long Intl phrasing, + * then resolves the words through the catalog so it localizes. */ -function relativeAgo(now: number, iso: string): string { +function relativeAgo(now: number, iso: string, t: TFunction): string { const then = Date.parse(iso) if (Number.isNaN(then)) - return 'recent' + return t('actionInput.freshness.recent') const delta = Math.max(0, now - then) - const m = Math.floor(delta / 60_000) - if (m < 1) - return 'just now' - if (m < 60) - return `${m}m ago` - const h = Math.floor(m / 60) - if (h < 24) - return `${h}h ago` - const d = Math.floor(h / 24) - return `${d}d ago` + const minutes = Math.floor(delta / 60_000) + if (minutes < 1) + return t('actionInput.freshness.justNow') + if (minutes < 60) + return t('actionInput.freshness.minutesAgo', { count: minutes }) + const hours = Math.floor(minutes / 60) + if (hours < 24) + return t('actionInput.freshness.hoursAgo', { count: hours }) + const days = Math.floor(hours / 24) + return t('actionInput.freshness.daysAgo', { count: days }) } function SelectControl({ diff --git a/packages/studio/src/components/AddSourceDialog.tsx b/packages/studio/src/components/AddSourceDialog.tsx index 58697da5..7e6bb42c 100644 --- a/packages/studio/src/components/AddSourceDialog.tsx +++ b/packages/studio/src/components/AddSourceDialog.tsx @@ -1,8 +1,11 @@ +import { localize } from '@braidhq/schema' import { useMutation } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { api } from '@/lib/api' import { humaniseApiError } from '@/lib/errors' +import { useLocale } from '@/lib/i18n' import { useOntology, useSourceLoaders } from '@/lib/queries' import { loaderKindLabel, nameToId, type SourceDraft, STUDIO_KNOWN_LOADER_KINDS, toSourceDescriptor } from '@/lib/sourceDraft' import { useGoogleOAuth } from '@/lib/useGoogleOAuth' @@ -22,6 +25,8 @@ interface AddSourceDialogProps { } export function AddSourceDialog({ workspaceId, open, onOpenChange, onAdded }: AddSourceDialogProps) { + const { t } = useTranslation() + const { locale } = useLocale() const sourceLoaders = useSourceLoaders() const ontology = useOntology(workspaceId) const roles = ontology.data?.sourceRoles ?? [] @@ -138,36 +143,36 @@ export function AddSourceDialog({ workspaceId, open, onOpenChange, onAdded }: Ad > - Add Source - Source rows are appended to PRODUCT.md and provisioned if a loader is set. + {t('sources.addDialog.title')} + {t('sources.addDialog.description')}
- + - + {/* Radix reserves the empty value, so a "none" sentinel maps to "". */}
- - setName(e.target.value)} placeholder={`${role || 'source'}-name`} autoFocus /> + + setName(e.target.value)} placeholder={t('sources.addDialog.namePlaceholder', { role: role || 'source' })} autoFocus />

./ {pathSegment} @@ -179,47 +184,47 @@ export function AddSourceDialog({ workspaceId, open, onOpenChange, onAdded }: Ad id="add-source-desc" value={description} onChange={setDescription} - label="What is this source?" - placeholder="e.g. what this source holds and how authoritative it is." - helperText="Visible to skills via PRODUCT.md." + label={t('sources.addDialog.descriptionFieldLabel')} + placeholder={t('sources.addDialog.descriptionPlaceholder')} + helperText={t('sources.addDialog.descriptionHint')} rows={2} /> {loaderKind === 'git' && (

- + setGitUrl(e.target.value)} placeholder="https://github.com/org/repo.git" /> - + setGitBranch(e.target.value)} placeholder="master" />
)} {loaderKind === 'gdrive' && ( <> - + setGdriveFolderId(e.target.value)} placeholder="1abc…" /> {gdriveFolderId.trim() === 'root' && (

- "root" mirrors your entire My Drive (rejected by the loader). Create a dedicated subfolder and paste its ID. + {t('sources.addDialog.googleDriveRootWarning')}

)}
- + setGdriveInclude(e.target.value)} placeholder="^docs/" /> - + setGdriveExclude(e.target.value)} placeholder="\\.tmp$" />
-

Google Account

+

{t('sources.addDialog.googleAccountTitle')}

{oauthConnected - ? `Connected for source "${sourceId}". Re-renaming will require re-connecting.` - : 'Connect a Google account that has read access to the folder above.'} + ? t('sources.addDialog.googleDriveConnected', { sourceId }) + : t('sources.addDialog.googleDriveConnectHint')}

{startOauth.error && ( @@ -241,15 +246,15 @@ export function AddSourceDialog({ workspaceId, open, onOpenChange, onAdded }: Ad {loaderKind === 'github' && ( <>
- + setGithubOwner(e.target.value)} placeholder="anthropics" /> - + setGithubRepo(e.target.value)} placeholder="claude-code" />
- + - + setGithubLabels(e.target.value)} placeholder="bug, p1" />

- Auth: server reads + {t('sources.addDialog.githubAuthPrefix')} {' '} $GH_TOKEN {' '} - at sync time. Without one you get GitHub's 60 req/h anonymous rate limit. + {t('sources.addDialog.githubAuthSuffix')}

)} @@ -283,15 +288,15 @@ export function AddSourceDialog({ workspaceId, open, onOpenChange, onAdded }: Ad kind={loaderKind} hint={( <> - This loader plugin is registered on the server but Studio does not ship a per-field config for it. To use it, edit + {t('sources.addDialog.unknownLoaderHintPrefix')} {' '} PRODUCT.md {' '} - directly and add the + {t('sources.addDialog.unknownLoaderHintMiddle')} {' '} {loaderKind} {' '} - config under this source. + {t('sources.addDialog.unknownLoaderHintSuffix')} )} /> @@ -299,10 +304,10 @@ export function AddSourceDialog({ workspaceId, open, onOpenChange, onAdded }: Ad {add.error &&

{humaniseApiError(add.error)}

}
- + diff --git a/packages/studio/src/components/BatchInFlightBanner.tsx b/packages/studio/src/components/BatchInFlightBanner.tsx index aa8ac285..f41def8e 100644 --- a/packages/studio/src/components/BatchInFlightBanner.tsx +++ b/packages/studio/src/components/BatchInFlightBanner.tsx @@ -1,4 +1,5 @@ import { AlertCircle, CheckCircle2, Sparkles } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { useBatchStatus } from '@/lib/queries' import { TopBanner } from './TopBanner' import { Button } from './ui/button' @@ -17,6 +18,7 @@ type Mode = // Cross-surface entry to the Batch view. // Hidden on the Batch surface itself, and when no plan exists. export function BatchInFlightBanner({ workspaceId, onOpenBatch, suppress }: BatchInFlightBannerProps) { + const { t } = useTranslation() const { data: plan } = useBatchStatus(workspaceId ?? undefined) // `archived` is the user's explicit "I'm done seeing this" signal, // so the banner stays hidden until a new plan kicks off. @@ -29,9 +31,9 @@ export function BatchInFlightBanner({ workspaceId, onOpenBatch, suppress }: Batc let mode: Mode if (plan.status === 'running') - mode = { kind: 'active', label: 'Bootstrap Running', completed, total } + mode = { kind: 'active', label: t('review.banners.bootstrapRunning'), completed, total } else if (plan.status === 'deriving') - mode = { kind: 'active', label: 'Deriving Units…', completed, total } + mode = { kind: 'active', label: t('review.banners.derivingUnits'), completed, total } else if ((plan.status === 'failed' || plan.status === 'stopped') && unfinished) mode = { kind: 'resumable', completed, total } else @@ -50,16 +52,9 @@ export function BatchInFlightBanner({ workspaceId, onOpenBatch, suppress }: Batc tone="batch" label={mode.label} detail={mode.total > 0 - ? ( - <> - {mode.completed} - {' / '} - {mode.total} - {' units'} - - ) + ? t('review.banners.unitsProgress', { completed: mode.completed, total: mode.total }) : ''} - actions={actions('View Progress')} + actions={actions(t('review.banners.viewProgressButton'))} /> ) } @@ -68,17 +63,10 @@ export function BatchInFlightBanner({ workspaceId, onOpenBatch, suppress }: Batc return ( - {mode.completed} - {' / '} - {mode.total} - {' units done'} - - )} - actions={actions('Resume')} + detail={t('review.banners.unitsDone', { completed: mode.completed, total: mode.total })} + actions={actions(t('review.banners.resumeButton'))} /> ) } @@ -86,18 +74,11 @@ export function BatchInFlightBanner({ workspaceId, onOpenBatch, suppress }: Batc return ( - {mode.completed} - {' / '} - {mode.total} - {' units'} - - )} - actions={actions('View Report')} + detail={t('review.banners.unitsProgress', { completed: mode.completed, total: mode.total })} + actions={actions(t('review.banners.viewReportButton'))} /> ) } diff --git a/packages/studio/src/components/CommandPalette.tsx b/packages/studio/src/components/CommandPalette.tsx index aaaa9681..563505b9 100644 --- a/packages/studio/src/components/CommandPalette.tsx +++ b/packages/studio/src/components/CommandPalette.tsx @@ -1,6 +1,7 @@ import type { SkillManifest, Workspace } from '@braidhq/schema' import { Activity, ClipboardCheck, GitGraph, HelpCircle, Network, Settings, Settings2, Sparkles } from 'lucide-react' import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { CommandDialog, CommandEmpty, @@ -24,13 +25,6 @@ interface CommandPaletteProps { export type Surface = 'actions' | 'activity' | 'batch' | 'clarifications' | 'history' | 'proposals' | 'settings' -interface SurfaceItem { - id: Surface | null - label: string - Icon: typeof Sparkles - shortcut: string -} - type ChordTarget = { kind: 'surface', surface: Surface | null } | { kind: 'workspace-details' } function chordSecondKey(key: string): ChordTarget | undefined { @@ -47,15 +41,16 @@ function chordSecondKey(key: string): ChordTarget | undefined { } } -const SURFACE_ITEMS: SurfaceItem[] = [ - { id: null, label: 'Graph (home)', Icon: Network, shortcut: 'G G' }, - { id: 'actions', label: 'Actions', Icon: Sparkles, shortcut: 'G A' }, - { id: 'clarifications', label: 'Clarifications', Icon: HelpCircle, shortcut: 'G C' }, - { id: 'proposals', label: 'Proposals', Icon: ClipboardCheck, shortcut: 'G P' }, - { id: 'activity', label: 'Activity', Icon: Activity, shortcut: 'G B' }, - { id: 'history', label: 'History', Icon: GitGraph, shortcut: 'G H' }, - { id: 'settings', label: 'Settings', Icon: Settings, shortcut: 'G S' }, -] +// `as const` keeps labelKey literal so t() validates each against the typed catalog. +const SURFACE_ITEMS = [ + { id: null, labelKey: 'shell.commandPalette.graphHome', Icon: Network, shortcut: 'G G' }, + { id: 'actions', labelKey: 'shell.surfaces.actions', Icon: Sparkles, shortcut: 'G A' }, + { id: 'clarifications', labelKey: 'shell.surfaces.clarifications', Icon: HelpCircle, shortcut: 'G C' }, + { id: 'proposals', labelKey: 'shell.surfaces.proposals', Icon: ClipboardCheck, shortcut: 'G P' }, + { id: 'activity', labelKey: 'shell.surfaces.activity', Icon: Activity, shortcut: 'G B' }, + { id: 'history', labelKey: 'shell.surfaces.history', Icon: GitGraph, shortcut: 'G H' }, + { id: 'settings', labelKey: 'shell.surfaces.settings', Icon: Settings, shortcut: 'G S' }, +] as const satisfies readonly { id: Surface | null, labelKey: string, Icon: typeof Sparkles, shortcut: string }[] function isTypingTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) @@ -76,6 +71,7 @@ export function CommandPalette({ onSelectSurface, onOpenWorkspaceDetails, }: CommandPaletteProps) { + const { t } = useTranslation() const [open, setOpen] = useState(false) const { data: skillData } = useSkills(activeWorkspaceId ?? undefined) @@ -133,13 +129,13 @@ export function CommandPalette({ const skills = (skillData?.items ?? []).filter((s: SkillManifest) => !s.frontmatter.braid.hidden) return ( - - + + - No results. + {t('shell.commandPalette.noMatches')} - - {SURFACE_ITEMS.map(({ id, label, Icon, shortcut }) => ( + + {SURFACE_ITEMS.map(({ id, labelKey, Icon, shortcut }) => ( { @@ -149,7 +145,7 @@ export function CommandPalette({ disabled={(id !== 'settings' && !activeWorkspaceId) || activeSurface === id} > - {label} + {t(labelKey)} {shortcut} ))} @@ -162,14 +158,14 @@ export function CommandPalette({ }} > - Workspace Settings + {t('shell.commandPalette.workspaceSettings')} G W )} {workspaces.length > 0 && ( - + {workspaces.map(ws => ( 0 && ( - + {skills.map((skill: SkillManifest) => ( void } -const WIZARD_ERROR_CASES: readonly ErrorCase[] = [ - { - match: e => e.status === 400 && e.message.includes('already exists'), - message: 'A workspace with that name already exists. Open it from the sidebar, or delete it first to recreate.', - }, -] - const STEP_ORDER: StepKey[] = ['basics', 'sources', 'mcp', 'advanced', 'confirm', 'progress'] -const STEP_LABELS: Record = { - basics: 'Basics', - sources: 'Sources', - mcp: 'MCP Servers', - advanced: 'Advanced', - confirm: 'Review', - progress: 'Creating', +const STEP_LABEL_KEYS: Record = { + basics: 'workspace.wizard.stepBasics', + sources: 'workspace.wizard.stepSources', + mcp: 'workspace.wizard.stepMcp', + advanced: 'workspace.wizard.stepAdvanced', + confirm: 'workspace.wizard.stepConfirm', + progress: 'workspace.wizard.stepProgress', } export function CreateWorkspaceWizard({ open, onOpenChange, onCreated }: CreateWorkspaceWizardProps) { + const { t } = useTranslation() const queryClient = useQueryClient() const [step, setStep] = useState('basics') const [name, setName] = useState('') @@ -135,12 +133,12 @@ export function CreateWorkspaceWizard({ open, onOpenChange, onCreated }: CreateW > - Create Workspace + {t('workspace.wizard.createTitle')} - Scaffolds a fresh workspace under + {t('workspace.wizard.createDescriptionPrefix')} {' '} ~/.braid/workspaces/ - . To open an existing one, pick it from the sidebar. + {t('workspace.wizard.createDescriptionSuffix')} @@ -215,10 +213,10 @@ export function CreateWorkspaceWizard({ open, onOpenChange, onCreated }: CreateW disabled={STEP_ORDER.indexOf(step) === 0} > - Back + {t('common.back')}
- + {step === 'confirm' ? ( ) : ( )} @@ -247,6 +245,7 @@ export function CreateWorkspaceWizard({ open, onOpenChange, onCreated }: CreateW } function StepIndicator({ step }: { step: StepKey }) { + const { t } = useTranslation() const current = STEP_ORDER.indexOf(step) return (
    @@ -262,7 +261,7 @@ function StepIndicator({ step }: { step: StepKey }) { > {index + 1} - {STEP_LABELS[key]} + {t(STEP_LABEL_KEYS[key])} {index < STEP_ORDER.length - 2 && } ) @@ -279,32 +278,33 @@ function BasicsStep({ name, description, onName, onDescription }: { onName: (v: string) => void onDescription: (v: string) => void }) { + const { t } = useTranslation() const invalid = name.length > 0 && !WORKSPACE_NAME_PATTERN.test(name) return (
    - + onName(e.target.value)} /> -

    Lowercase letters, digits, and dashes. Name conflicts are rejected; delete the existing workspace first to reuse a name.

    +

    {t('workspace.wizard.nameHint')}

    ~/.braid/workspaces/ {name || ''} {invalid && ( -

    Name must start with a letter or digit and use only lowercase letters, digits, or dashes.

    +

    {t('workspace.wizard.nameInvalid')}

    )}
    ) @@ -318,6 +318,8 @@ function SourcesStep({ workspaceName, roles, sources, oauthConnectedFor, onChang onChange: (sources: SourceDraft[]) => void onOauthConnected: (sourceId: string) => void }) { + const { t } = useTranslation() + const { locale } = useLocale() function add(role: SourceRoleDescriptor) { onChange([...sources, defaultSourceDraft(role)]) } @@ -331,12 +333,11 @@ function SourcesStep({ workspaceName, roles, sources, oauthConnectedFor, onChang return (

    - Add a source for each role the ontology declares. A loader places files - under the workspace folder; pick + {t('workspace.wizard.sourcesDescriptionPrefix')} {' '} manual {' '} - to manage that path yourself. You can also skip this step and add sources later. + {t('workspace.wizard.sourcesDescriptionSuffix')}

    {sources.map(source => ( @@ -355,9 +356,7 @@ function SourcesStep({ workspaceName, roles, sources, oauthConnectedFor, onChang {roles.map(role => ( ))}
    @@ -373,6 +372,7 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o onRemove: () => void onOauthConnected: (sourceId: string) => void }) { + const { t } = useTranslation() const id = nameToId(draft.name) const targetPath = `./${draftPathSegment(draft)}/${id || ''}` return ( @@ -380,7 +380,7 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o
    {draft.role} onUpdate({ name: e.target.value })} className="flex-1" @@ -397,9 +397,9 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o id={`src-desc-${draft.uiId}`} value={draft.description} onChange={next => onUpdate({ description: next })} - label="What is this source?" - placeholder="e.g. what this source holds and how authoritative it is." - helperText="Visible to skills via PRODUCT.md." + label={t('workspace.wizard.sourceDescriptionLabel')} + placeholder={t('workspace.wizard.sourceDescriptionPlaceholder')} + helperText={t('workspace.wizard.sourceDescriptionHint')} rows={2} /> @@ -413,7 +413,7 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o className="flex-1" /> onUpdate({ gitBranch: e.target.value })} className="w-28" @@ -423,18 +423,18 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o {draft.loaderKind === 'gdrive' && ( <> onUpdate({ gdriveFolderId: e.target.value })} />
    onUpdate({ gdriveInclude: e.target.value })} /> onUpdate({ gdriveExclude: e.target.value })} /> @@ -452,15 +452,15 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o kind={draft.loaderKind} hint={( <> - This loader plugin is registered on the server but Studio does not ship a per-field config for it. Scaffold the workspace with this source set to + {t('workspace.wizard.unknownLoaderHintBefore')} {' '} manual {' '} - and add the + {t('workspace.wizard.unknownLoaderHintMiddle')} {' '} {draft.loaderKind} {' '} - config to PRODUCT.md afterwards. + {t('workspace.wizard.unknownLoaderHintAfter')} )} /> @@ -479,6 +479,7 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o * It just tells the workspace the source has no auto-sync. */ function LoaderSelect({ value, onChange }: { value: string, onChange: (kind: string) => void }) { + const { t } = useTranslation() const { data } = useSourceLoaders() return ( ) @@ -500,6 +501,7 @@ function GdriveOauthBlock({ workspaceName, sourceName, connected, onConnected }: connected: boolean onConnected: (sourceId: string) => void }) { + const { t } = useTranslation() // Token storage key is `${workspaceId}--${sourceId}`. // Workspace id is the typed name, the PRODUCT.md name, // source id is derived from the source name. @@ -515,13 +517,13 @@ function GdriveOauthBlock({ workspaceName, sourceName, connected, onConnected }:
    -

    Google Account

    +

    {t('workspace.wizard.googleAccount')}

    {!canStart - ? 'Set workspace name and source name first.' + ? t('workspace.wizard.googleAccountSetNames') : connected - ? `Connected for "${workspaceId}/${sourceId}".` - : 'Authorise read access to the folder above. Required before the workspace is created.'} + ? t('workspace.wizard.googleAccountConnected', { workspaceId, sourceId }) + : t('workspace.wizard.googleAccountAuthorise')}

    {startOauth.error && ( @@ -544,6 +546,7 @@ function McpStep({ servers, onChange }: { servers: McpDraft[] onChange: (servers: McpDraft[]) => void }) { + const { t } = useTranslation() function add() { onChange([...servers, { uiId: crypto.randomUUID(), id: '', url: '', description: '', headersText: '' }]) } @@ -557,21 +560,21 @@ function McpStep({ servers, onChange }: { return (

    - Optional. MCP endpoints the agent can call during extract / validate (e.g. Linear, Redmine, Jira) to fill gaps in your intent / code sources; they are not provisioned as content sources themselves. Only Streamable HTTP transport is supported. Use + {t('workspace.wizard.mcpDescriptionPrefix')} {' '} $ {'{ENV_VAR}'} {' '} - in header values for secrets (resolved at runtime, never written to PRODUCT.md). + {t('workspace.wizard.mcpDescriptionSuffix')}

    {servers.map(server => (
    update(server.uiId, { id: e.target.value })} className="w-40" @@ -590,9 +593,9 @@ function McpStep({ servers, onChange }: { id={`mcp-desc-${server.uiId}`} value={server.description} onChange={next => update(server.uiId, { description: next })} - label="What does this MCP serve?" - placeholder="e.g. Linear, source of truth for tickets." - helperText="Visible to skills via PRODUCT.md." + label={t('workspace.wizard.mcpDescriptionLabel')} + placeholder={t('workspace.wizard.mcpDescriptionPlaceholder')} + helperText={t('workspace.wizard.sourceDescriptionHint')} rows={2} />