feat(i18n): translate onboarding namespace (welcome + step header)

Translates the user-first-impression surfaces of the onboarding flow:

- step-welcome.tsx (the wordmark, headline, lede paragraphs, all CTAs:
  Download Desktop / Continue on web / Start exploring / I've done
  this before, illustration caption)
- step-header.tsx ("Step N of M" counter + matching aria-label)
- onboarding-flow.tsx (skip-onboarding error toast)

Test wrapper added to onboarding/components/step-header.test.tsx —
custom render() helper wraps with <I18nProvider> so the "Step 2 of 5"
assertions match the EN bundle.

Deferred (acceptable English fallback for now): step-questionnaire,
step-workspace, step-runtime-connect, step-platform-fork, step-agent,
step-first-issue, cli-install-instructions, option-card, runtime
aside panels, starter-content-prompt, cloud-waitlist-expand. These
are deeper steps with significant copy that would benefit from a
focused dedicated pass — voice on each is more nuanced (questionnaire
options, runtime install instructions, agent template recommendations).

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-30 16:24:11 +08:00
parent 2c0a362692
commit 5f3d0fc55b
8 changed files with 94 additions and 25 deletions

View File

@@ -85,6 +85,9 @@ const TRANSLATED_FILES = [
"layout/app-sidebar.tsx",
"layout/help-launcher.tsx",
"layout/workspace-loader.tsx",
"onboarding/components/step-header.tsx",
"onboarding/steps/step-welcome.tsx",
"onboarding/onboarding-flow.tsx",
];
export default [

View File

@@ -1 +1,22 @@
{}
{
"step_header": {
"step_of": "Step {{current}} of {{total}}"
},
"welcome": {
"wordmark": "Welcome to Multica",
"headline_line1": "Your AI teammates,",
"headline_line2": "in",
"headline_emphasis": "one workspace.",
"lede": "Assign them work like you'd assign a colleague — they pick it up, update status, and comment when done.",
"lede_web": "Desktop bundles the runtime — nothing to install. Continue on web to connect your own CLI.",
"lede_desktop": "By the end, a real agent will be replying to your first issue.",
"download_desktop": "Download Desktop",
"continue_on_web": "Continue on web",
"start_exploring": "Start exploring",
"skip_existing": "I've done this before",
"illustration_caption": "Every issue, every thread, every decision — shared by your team and agents."
},
"errors": {
"skip_failed": "Failed to finish onboarding"
}
}

View File

@@ -3,6 +3,7 @@ import enCommon from "./en/common.json";
import enAuth from "./en/auth.json";
import enSettings from "./en/settings.json";
import enEditor from "./en/editor.json";
import enOnboarding from "./en/onboarding.json";
import enInvite from "./en/invite.json";
import enLabels from "./en/labels.json";
import enMembers from "./en/members.json";
@@ -21,6 +22,7 @@ import zhHansCommon from "./zh-Hans/common.json";
import zhHansAuth from "./zh-Hans/auth.json";
import zhHansSettings from "./zh-Hans/settings.json";
import zhHansEditor from "./zh-Hans/editor.json";
import zhHansOnboarding from "./zh-Hans/onboarding.json";
import zhHansInvite from "./zh-Hans/invite.json";
import zhHansLabels from "./zh-Hans/labels.json";
import zhHansMembers from "./zh-Hans/members.json";
@@ -45,6 +47,7 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
auth: enAuth,
settings: enSettings,
editor: enEditor,
onboarding: enOnboarding,
invite: enInvite,
labels: enLabels,
members: enMembers,
@@ -65,6 +68,7 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
auth: zhHansAuth,
settings: zhHansSettings,
editor: zhHansEditor,
onboarding: zhHansOnboarding,
invite: zhHansInvite,
labels: zhHansLabels,
members: zhHansMembers,

View File

@@ -1 +1,22 @@
{}
{
"step_header": {
"step_of": "第 {{current}} 步 / 共 {{total}} 步"
},
"welcome": {
"wordmark": "欢迎使用 Multica",
"headline_line1": "你的 AI 队友,",
"headline_line2": "在",
"headline_emphasis": "同一个工作区。",
"lede": "像分配给同事一样把任务交给它们——它们会接手、推进状态、完成后留下评论。",
"lede_web": "桌面端自带运行时,无需安装。在 web 端继续可以连接你自己的 CLI。",
"lede_desktop": "完成本流程后,真实的智能体会回复你创建的第一个 issue。",
"download_desktop": "下载桌面端",
"continue_on_web": "在 web 端继续",
"start_exploring": "开始探索",
"skip_existing": "我之前做过了",
"illustration_caption": "每个 issue、每条对话、每个决策——团队和智能体共享同一份上下文。"
},
"errors": {
"skip_failed": "完成上手引导失败"
}
}

View File

@@ -1,8 +1,28 @@
import type { ReactNode } from "react";
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { render as rtlRender, screen, type RenderOptions } from "@testing-library/react";
import { ONBOARDING_STEP_ORDER } from "@multica/core/onboarding";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enOnboarding from "../../locales/en/onboarding.json";
import { StepHeader } from "./step-header";
const TEST_RESOURCES = {
en: { common: enCommon, onboarding: enOnboarding },
};
function I18nWrapper({ children }: { children: ReactNode }) {
return (
<I18nProvider locale="en" resources={TEST_RESOURCES}>
{children}
</I18nProvider>
);
}
function render(ui: React.ReactElement, options?: RenderOptions) {
return rtlRender(ui, { wrapper: I18nWrapper, ...options });
}
describe("StepHeader", () => {
it("renders one dot per step in ONBOARDING_STEP_ORDER", () => {
const { container } = render(<StepHeader currentStep="questionnaire" />);

View File

@@ -5,6 +5,7 @@ import {
type OnboardingStep,
} from "@multica/core/onboarding";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
/**
* Horizontal step indicator shown at the top of every onboarding step
@@ -30,6 +31,7 @@ import { cn } from "@multica/ui/lib/utils";
* mapping from local UI step to the canonical `OnboardingStep`.
*/
export function StepHeader({ currentStep }: { currentStep: OnboardingStep }) {
const { t } = useT("onboarding");
const total = ONBOARDING_STEP_ORDER.length;
const currentIndex = ONBOARDING_STEP_ORDER.indexOf(currentStep);
// Defensive: unknown step → render a disabled-looking header rather
@@ -43,7 +45,7 @@ export function StepHeader({ currentStep }: { currentStep: OnboardingStep }) {
aria-valuemin={1}
aria-valuemax={total}
aria-valuenow={safeIndex + 1}
aria-label={`Step ${safeIndex + 1} of ${total}`}
aria-label={t(($) => $.step_header.step_of, { current: safeIndex + 1, total })}
className="flex w-full items-center justify-between py-2"
>
<div className="flex items-center gap-2">
@@ -65,7 +67,7 @@ export function StepHeader({ currentStep }: { currentStep: OnboardingStep }) {
})}
</div>
<span className="text-xs font-medium text-muted-foreground">
Step {safeIndex + 1} of {total}
{t(($) => $.step_header.step_of, { current: safeIndex + 1, total })}
</span>
</div>
);

View File

@@ -24,6 +24,7 @@ import { StepRuntimeConnect } from "./steps/step-runtime-connect";
import { StepPlatformFork } from "./steps/step-platform-fork";
import { StepAgent } from "./steps/step-agent";
import { StepFirstIssue } from "./steps/step-first-issue";
import { useT } from "../i18n";
const EMPTY_QUESTIONNAIRE: QuestionnaireAnswers = {
team_size: null,
@@ -55,6 +56,7 @@ export function OnboardingFlow({
onComplete: (workspace?: Workspace) => void;
runtimeInstructions?: React.ReactNode;
}) {
const { t } = useT("onboarding");
const user = useAuthStore((s) => s.user);
if (!user) {
throw new Error("OnboardingFlow requires an authenticated user");
@@ -114,7 +116,7 @@ export function OnboardingFlow({
await completeOnboarding("skip_existing");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to finish onboarding",
err instanceof Error ? err.message : t(($) => $.errors.skip_failed),
);
return;
}

View File

@@ -11,6 +11,7 @@ import { STATUS_CONFIG } from "@multica/core/issues/config";
import type { IssueStatus } from "@multica/core/types";
import { StatusIcon } from "../../issues/components/status-icon";
import { ProviderLogo } from "../../runtimes/components/provider-logo";
import { useT } from "../../i18n";
/**
* Step 0 — the one-shot product intro shown on every onboarding
@@ -45,6 +46,7 @@ export function StepWelcome({
onSkip?: () => void | Promise<void>;
isWeb?: boolean;
}) {
const { t } = useT("onboarding");
// Tracks which button is mid-flight so we can show a per-button
// spinner and disable both while one is in progress.
const [pending, setPending] = useState<"next" | "skip" | null>(null);
@@ -79,30 +81,25 @@ export function StepWelcome({
<div className="flex items-center gap-2.5">
<MulticaIcon className="size-5 text-foreground" noSpin />
<span className="font-serif text-xl font-medium tracking-tight">
Welcome to Multica
{t(($) => $.welcome.wordmark)}
</span>
</div>
<h1 className="text-balance font-serif text-5xl font-medium leading-[1.04] tracking-tight sm:text-6xl">
Your AI teammates,
{t(($) => $.welcome.headline_line1)}
<br />
in <em className="italic text-brand">one workspace.</em>
{t(($) => $.welcome.headline_line2)}{" "}
<em className="italic text-brand">{t(($) => $.welcome.headline_emphasis)}</em>
</h1>
<div className="flex flex-col gap-4">
<p className="text-lg leading-relaxed text-foreground/85">
Assign them work like you&apos;d assign a colleague they
pick it up, update status, and comment when done.
{t(($) => $.welcome.lede)}
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
{isWeb ? (
<>
Desktop bundles the runtime nothing to install.
Continue on web to connect your own CLI.
</>
) : (
"By the end, a real agent will be replying to your first issue."
)}
{isWeb
? t(($) => $.welcome.lede_web)
: t(($) => $.welcome.lede_desktop)}
</p>
</div>
@@ -124,7 +121,7 @@ export function StepWelcome({
className={buttonVariants({ size: "lg" })}
>
<Download className="h-4 w-4" />
Download Desktop
{t(($) => $.welcome.download_desktop)}
</a>
<Button
size="lg"
@@ -135,7 +132,7 @@ export function StepWelcome({
{pending === "next" && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Continue on web
{t(($) => $.welcome.continue_on_web)}
<ArrowRight className="h-4 w-4" />
</Button>
</>
@@ -148,7 +145,7 @@ export function StepWelcome({
{pending === "next" && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Start exploring
{t(($) => $.welcome.start_exploring)}
<ArrowRight className="h-4 w-4" />
</Button>
)}
@@ -162,7 +159,7 @@ export function StepWelcome({
{pending === "skip" && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
I&apos;ve done this before
{t(($) => $.welcome.skip_existing)}
</Button>
)}
</div>
@@ -180,8 +177,7 @@ export function StepWelcome({
<DragStrip />
<div className="flex flex-1 flex-col items-center justify-center gap-7 px-8 py-8">
<p className="max-w-[440px] text-balance text-center font-serif text-[15px] italic leading-snug text-muted-foreground">
Every issue, every thread, every decision shared by your team and
agents.
{t(($) => $.welcome.illustration_caption)}
</p>
<WelcomeIllustration />
</div>