diff --git a/apps/presentation/dashboard/README.md b/apps/presentation/dashboard/README.md index 033cd73269..9806eb1e4b 100644 --- a/apps/presentation/dashboard/README.md +++ b/apps/presentation/dashboard/README.md @@ -9,6 +9,28 @@ Personal Workspace is the operator UI for Goals, Tasks, Chat, and outputs. The CLI and versioned control-plane projections remain the sources of truth; the UI does not create a second state authority. +## Workspace Language / 工作区语言 + +On first use, the workspace follows the browser's ordered language preferences: +English uses English, and Chinese uses the available Simplified Chinese catalog. +If neither language matches or language information is unavailable, it uses +English. This replaces the previous unconditional Chinese default. + +Choose **Settings → Language** to override detection. Explicit choices remain in +the existing `loopx-pw-locale` browser storage entry and survive reloads and +upgrades on the same origin. A different host/port, cleared browser storage, or a +fresh desktop webview starts detection again. When storage is blocked, language +changes last for the current session only. + +首次打开时,工作区按浏览器语言偏好顺序选择英文或简体中文;没有匹配语言或无法 +获取语言信息时使用英文,替代原来固定使用中文的默认行为。 +可在 **设置 → 语言** 手动选择,同一站点来源下的刷新和升级会保留选择。 +更换主机或端口、清除浏览器存储或使用新的桌面 WebView 后会重新检测;浏览器 +禁止存储时,手动选择仅在当前会话有效。 + +Validate this behavior with `npm run smoke:workspace-locale`; the same cases also +run in the full development and packaged personal-workspace browser smokes. + ## Fresh Clone Public Preview No private LoopX state is required for the first dashboard preview. The diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 705db3cee7..d4bf0fa8f0 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -31,6 +31,7 @@ "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js", "smoke:personal-workspace": "npm run smoke:goal-order && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:personal-workspace-router": "rm -rf /tmp/loopx-personal-workspace-router-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-personal-workspace-router-smoke smoke/personal-workspace-router-smoke.ts src/features/personal-workspace/personal-workspace-router.ts && node /tmp/loopx-personal-workspace-router-smoke/smoke/personal-workspace-router-smoke.js", "smoke:presentation-surface-schema": "rm -rf /tmp/loopx-presentation-surface-schema-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-presentation-surface-schema-smoke smoke/presentation-surface-schema-smoke.ts src/data/status.ts src/data/decision-research.ts src/data/goal-channel-frontstage.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-presentation-surface-schema-smoke/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.js", diff --git a/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs new file mode 100644 index 0000000000..4d5064ef18 --- /dev/null +++ b/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; + +// Exercise the shipped provider and Settings UI against the shared synthetic API. +export async function verifyWorkspaceLocales({ browser, url, installApi, outputDir }) { + const cases = [ + { name: "fresh English", locale: "en-US", expected: "en" }, + { name: "fresh Chinese", locale: "zh-CN", expected: "zh-CN" }, + { name: "Chinese region", locale: "zh-TW", expected: "zh-CN" }, + { name: "unsupported language", locale: "fr-FR", expected: "en" }, + { name: "ordered preferences", languages: ["fr-FR", "en-GB", "zh-CN"], expected: "en" }, + { name: "secondary Chinese", languages: ["fr-FR", "zh-CN", "en"], expected: "zh-CN" }, + { name: "language fallback", languages: [], locale: "zh-CN", expected: "zh-CN" }, + { name: "missing language", languages: [], language: "", expected: "en" }, + { name: "saved English", stored: "en", locale: "zh-CN", expected: "en" }, + { name: "saved Chinese", stored: "zh-CN", locale: "en-US", expected: "zh-CN" }, + { name: "invalid preference", stored: "invalid", locale: "en-US", expected: "en" }, + { name: "locale storage failure English", blocked: true, locale: "en-US", expected: "en" }, + { name: "locale storage failure Chinese", blocked: true, locale: "zh-CN", expected: "zh-CN" }, + ]; + for (const scenario of cases) { + const page = await browser.newPage({ locale: scenario.locale ?? "en-US" }); + try { + await installApi(page); + await page.addInitScript(({ stored, blocked, languages, language }) => { + if (stored !== undefined) localStorage.setItem("loopx-pw-locale", stored); + if (languages !== undefined) Object.defineProperty(navigator, "languages", { value: languages }); + if (language !== undefined) Object.defineProperty(navigator, "language", { value: language }); + if (blocked) { + const getItem = Storage.prototype.getItem; + const setItem = Storage.prototype.setItem; + Storage.prototype.getItem = function (key) { + if (key === "loopx-pw-locale") throw new DOMException("Storage unavailable", "SecurityError"); + return getItem.call(this, key); + }; + Storage.prototype.setItem = function (key, value) { + if (key === "loopx-pw-locale") throw new DOMException("Storage unavailable", "SecurityError"); + return setItem.call(this, key, value); + }; + } + }, scenario); + await page.goto(url, { waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor(); + assert.equal(await page.locator("html").getAttribute("lang"), scenario.expected, scenario.name); + const label = scenario.expected === "en" ? "Settings" : "设置"; + assert.ok(await page.getByRole("button", { name: label, exact: true }).isVisible(), scenario.name); + if (!scenario.blocked) { + assert.equal(await page.evaluate(() => localStorage.getItem("loopx-pw-locale")), scenario.stored ?? null, + `${scenario.name}: detection must not overwrite a saved choice`); + } + if (scenario.blocked) { + await page.getByRole("button", { name: label, exact: true }).click(); + await page.getByRole("button", { name: /Language|语言/ }).click(); + const next = scenario.expected === "en" ? "zh-CN" : "en"; + await page.getByRole("radio", { name: next === "en" ? /English/ : /Simplified Chinese/ }).click(); + assert.equal(await page.locator("html").getAttribute("lang"), next, + "A locale write failure still permits changing the current session"); + } + console.log(`workspace locale: ${scenario.name}: ok`); + } finally { + await page.close(); + } + } + + const page = await browser.newPage({ locale: "en-US", viewport: { width: 1440, height: 960 } }); + try { + await installApi(page); + await page.goto(url, { waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor(); + await page.screenshot({ path: resolve(outputDir, "locale-default-english.png") }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator(".personal-channel-scroll").evaluate((element) => { element.scrollTop = 0; }); + const clippedStats = await page.locator(".personal-digest-stats span").evaluateAll((stats) => + stats.filter((stat) => { + const rect = stat.getBoundingClientRect(); + return rect.left < 0 || rect.right > window.innerWidth; + }).length, + ); + assert.equal(clippedStats, 0, "English summary statistics fit the mobile viewport"); + await page.screenshot({ path: resolve(outputDir, "locale-default-english-mobile.png") }); + await page.setViewportSize({ width: 1440, height: 960 }); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: /Language/ }).click(); + await page.getByRole("radio", { name: /Simplified Chinese/ }).click(); + assert.equal(await page.evaluate(() => localStorage.getItem("loopx-pw-locale")), "zh-CN"); + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor(); + assert.equal(await page.locator("html").getAttribute("lang"), "zh-CN"); + await page.screenshot({ path: resolve(outputDir, "locale-saved-chinese.png") }); + await page.getByRole("button", { name: "设置", exact: true }).click(); + await page.getByRole("button", { name: /语言/ }).click(); + await page.getByRole("radio", { name: /English/ }).click(); + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor(); + assert.equal(await page.locator("html").getAttribute("lang"), "en"); + assert.equal(await page.evaluate(() => localStorage.getItem("loopx-pw-locale")), "en"); + console.log("workspace locale: Settings choices survive reload in both directions: ok"); + } finally { + await page.close(); + } +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index e42f625573..d30f3ea61a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -2253,10 +2253,19 @@ function formatMessage(template: string, values?: Record) => string; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 31531524a0..f2f655fd8b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -1683,6 +1683,10 @@ .personal-digest-stats { display: flex; gap: 8px; } .personal-digest-stats span { display: inline-flex; align-items: baseline; gap: 6px; padding: 6px 13px; border: 1px solid var(--pw-line-strong); border-radius: 99px; background: #fff; color: var(--pw-muted); font-size: 12px; } .personal-digest-stats b { color: var(--pw-text); font-size: 14px; } +@media (max-width: 720px) { + .personal-digest-card { flex-direction: column; align-items: flex-start; } + .personal-digest-stats { flex-wrap: wrap; } +} diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index b7ea92b92f..5c69feb57e 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -31,8 +31,9 @@ import { stewardJourneyScenario } from "./personal-workspace-browser/steward-jou import { teamPlanScenario } from "./personal-workspace-browser/team-plan.mjs"; import { typedActionsScenario } from "./personal-workspace-browser/typed-actions.mjs"; import { stewardModelSettingsScenario } from "./personal-workspace-browser/steward-model-settings.mjs"; +import { workspaceLocaleScenario } from "./personal-workspace-browser/workspace-locale.mjs"; -const scenarioCatalog = [navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario]; +const scenarioCatalog = [navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario]; const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) @@ -59,7 +60,12 @@ async function main() { for (const scenario of scenarios) { const startedAt = Date.now(); try { - const result = await scenario.run({ browser, collectCoverage, url }); + // Existing scenarios assert Chinese copy; the locale scenario exercises + // browser preferences explicitly and receives the unmodified browser. + const scenarioBrowser = scenario.id === "workspace-locale" + ? browser + : { newPage: (options = {}) => browser.newPage({ locale: "zh-CN", ...options }) }; + const result = await scenario.run({ browser: scenarioBrowser, collectCoverage, url }); coverageEntries.push(...result.coverageEntries); results[scenario.id] = { status: "PASS", diff --git a/examples/personal-workspace-browser/workspace-locale.mjs b/examples/personal-workspace-browser/workspace-locale.mjs new file mode 100644 index 0000000000..d49914a3a2 --- /dev/null +++ b/examples/personal-workspace-browser/workspace-locale.mjs @@ -0,0 +1,10 @@ +import { verifyWorkspaceLocales } from "../../apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs"; +import { installApi, outputDir } from "./fixture.mjs"; + +export const workspaceLocaleScenario = { + id: "workspace-locale", + async run({ browser, url }) { + await verifyWorkspaceLocales({ browser, url, installApi, outputDir }); + return { coverageEntries: [], note: "First-use browser language, saved choice, storage fallback, Settings, and mobile layout passed" }; + }, +};