Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/presentation/dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
101 changes: 101 additions & 0 deletions apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2253,10 +2253,19 @@ function formatMessage(template: string, values?: Record<string, string | number

export function readWorkspaceLocale(): WorkspaceLocale {
try {
return window.localStorage.getItem(workspaceLocaleStorageKey) === "en" ? "en" : "zh-CN";
const stored = window.localStorage.getItem(workspaceLocaleStorageKey);
if (stored === "en" || stored === "zh-CN") return stored;
} catch {
return "zh-CN";
// Browser preferences still apply when persistent storage is unavailable.
}
if (typeof navigator === "undefined") return "en";
const languages = navigator.languages?.length ? navigator.languages : [navigator.language];
for (const language of languages) {
const primaryLanguage = language?.split("-")[0].toLowerCase();
if (primaryLanguage === "en") return "en";
if (primaryLanguage === "zh") return "zh-CN";
}
return "en";
}

export type WorkspaceTranslate = (key: WorkspaceMessageKey, values?: Record<string, string | number>) => string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}



Expand Down
10 changes: 8 additions & 2 deletions examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions examples/personal-workspace-browser/workspace-locale.mjs
Original file line number Diff line number Diff line change
@@ -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" };
},
};
Loading