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
27 changes: 27 additions & 0 deletions .changeset/studio-jargon-and-tool-card-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
'@object-ui/plugin-chatbot': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

Studio workbench and AI tool cards speak the author's language (objectui#7254)

- The Interfaces breadcrumb, canvas caption and navigation rail show the
metadata label plus a translated kind; the internal `type · name` pair moves
to the tooltip. An unlabelled nav leaf now falls back to its object name
instead of rendering an empty row.
- The Studio top-bar package switcher reads the package's human name from
either position the packages endpoint serves it in, instead of degrading a
registry-shaped entry to its reverse-domain id.
- The dashboard property panel is localized: the spec's authoring form is
overlaid through the platform's own `metadataForms.<type>` convention, so
section headings, field labels, hints and the `header` composite's sub-fields
render in Chinese (developer vocabulary such as "Tailwind units" is replaced
with something an author can act on, not transliterated).
- AI tool cards: tool titles resolve through `chatbot.tool.<name>` (all thirty
platform-provided tools, ten locale packs), the header status badge is
localized, and the plan count strip is a real plural family instead of an
English `+ "s"` concatenation.
- The tool card's header badge and its body badge now come from one producer:
a proposal that has been confirmed, built or published no longer keeps a
header reading "Awaiting Approval".
31 changes: 26 additions & 5 deletions packages/app-shell/src/views/metadata-admin/dashboard-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,8 @@
import { z } from 'zod';
import { DashboardSchema, dashboardForm as specDashboardForm } from '@objectstack/spec/ui';
import type { FormViewSpec } from './SchemaForm.js';
import type { SupportedLocale } from './i18n.js';
import { localizeMetadataForm } from './metadata-form-i18n.js';

type JsonSchema = Record<string, any>;

Expand DownExpand Up@@ -54,16 +56,28 @@ export function getDashboardSchema(): JsonSchema | undefined {
return _dashboardDocSchema;
}

let _dashboardForm: FormViewSpec | undefined;
/**
* Per-locale, because the form now carries LOCALIZED copy (objectui#7254) and
* a single slot would have served whichever locale asked first to everyone
* after it — a cache that is right until someone switches language.
*/
const _dashboardForm = new Map<string, FormViewSpec | undefined>();

/**
* The canonical authoring FormView, with the fields the curated inspector
* owns directly (widgets / label / description / name) pruned from every
* section so they are not double-rendered. Everything else — layout,
* filters, performance — flows through verbatim from the spec.
*
* The spec authors these strings in English. `localizeMetadataForm` overlays
* the active locale on top (objectui#7254) through the platform's own
* `metadataForms.<type>` convention, so the panel speaks the same language as
* the Studio around it. A locale the overlay does not carry gets the spec's
* English, exactly as before.
*/
export function getDashboardForm(): FormViewSpec | undefined {
if (_dashboardForm) return _dashboardForm;
export function getDashboardForm(locale?: SupportedLocale | string): FormViewSpec | undefined {
const cacheKey = String(locale ?? '');
if (_dashboardForm.has(cacheKey)) return _dashboardForm.get(cacheKey);
if (!specDashboardForm || typeof specDashboardForm !== 'object') return undefined;
try {
const clone = JSON.parse(JSON.stringify(specDashboardForm)) as FormViewSpec;
Expand All@@ -77,12 +91,19 @@ export function getDashboardForm(): FormViewSpec | undefined {
clone.sections = (clone.sections ?? []).filter(
(s: any) => (s.fields ?? []).length > 0,
);
_dashboardForm = clone;
// Localize AFTER pruning: the overlay only has to reach what renders, and
// pruning cannot drop a section the overlay just renamed.
const localized = localizeMetadataForm(
clone as unknown as Record<string, unknown>,
'dashboard',
locale,
) as unknown as FormViewSpec;
_dashboardForm.set(cacheKey, localized);
return localized;
} catch (err) {
if (typeof console !== 'undefined') {
console.warn('[dashboard-schema] failed to prepare dashboardForm from spec', err);
}
return undefined;
}
return _dashboardForm;
}
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1737,6 +1737,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.nav.noObjects': 'This package has no objects yet — create one in the Data pillar first.',
// Interfaces pillar
'engine.studio.if.pickLeft': 'Select a menu item on the left',
// objectui#7254 — the tooltip that keeps the DEVELOPER identity reachable
// after the breadcrumb/caption stopped printing it beside a localized
// label. Prefixed rather than bare: `dashboard · customer_dashboard` on
// its own does not say what it is, and it is also what the rail item's
// own tooltip carries — three elements sharing one tooltip string is a
// page nothing (a person or a test) can address unambiguously.
'engine.studio.if.internalId': 'Internal id',
'engine.studio.if.navHeading': '{app} · Navigation',
'engine.studio.if.editNavTitle': 'Edit navigation (drag to reorder / rename / add-remove)',
'engine.studio.if.doneEditTitle': 'Done editing',
Expand DownExpand Up@@ -3623,6 +3630,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.nav.noObjects': '这个软件包还没有对象 — 先到 Data 支柱创建。',
// Interfaces pillar
'engine.studio.if.pickLeft': '从左侧选择一个菜单项',
'engine.studio.if.internalId': '内部标识',
'engine.studio.if.navHeading': '{app} · 导航',
'engine.studio.if.editNavTitle': '编辑导航(拖拽排序 / 重命名 / 增删)',
'engine.studio.if.doneEditTitle': '完成编辑',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,7 +130,9 @@ export function DashboardDefaultInspector({
() =>
mergeServerFields({
bundledSchema: getDashboardSchema(),
bundledForm: getDashboardForm(),
// objectui#7254 — the spec form is English; the locale overlay lives
// inside `getDashboardForm` so every consumer of it gets the same copy.
bundledForm: getDashboardForm(locale),
serverSchema,
excludeFields: DASHBOARD_CURATED_FIELDS,
sectionTitle: t('engine.inspector.moreFields', locale),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7254 — the dashboard property panel was an all-English island
* inside a Chinese Studio.
*
* The panel is spec-driven on purpose (`dashboardForm` from
* `@objectstack/spec/ui` fed straight into SchemaForm), and the spec authors
* its copy in English, so every section heading, field label and hint reached
* the author untranslated — including "Grid gap (Tailwind units)", a unit only
* a developer can act on.
*
* These pins assert the OVERLAY, and specifically the two things a hand-rolled
* walker would have got wrong and a shape-only test would not have noticed:
* the section key is the SLUGGED label (not the label text), and a `composite`
* field's sub-rows are synthesized from the bundle — completely, or the ones
* left out vanish from the form.
*/
import { describe, expect, it } from 'vitest';
import { dashboardForm } from '@objectstack/spec/ui';
import { localizeMetadataForm, hasMetadataFormOverlay } from './metadata-form-i18n';
import { getDashboardForm } from './dashboard-schema';

/** The two form shapes these assertions read. `unknown` everywhere else. */
interface FormField {
field?: string;
type?: string;
label?: string;
helpText?: string;
fields?: FormField[];
}
interface Section {
label?: string;
description?: string;
fields?: FormField[];
}

function sections(form: unknown): Section[] {
return ((form as { sections?: Section[] } | undefined)?.sections ?? []) as Section[];
}

function fieldByName(form: unknown, name: string): FormField | undefined {
for (const s of sections(form)) {
for (const f of s.fields ?? []) {
if (f?.field === name) return f;
}
}
return undefined;
}

describe('localizeMetadataForm — the spec form, in the author’s language', () => {
it('has an overlay for `dashboard` at zh and none at en', () => {
expect(hasMetadataFormOverlay('dashboard', 'zh-CN')).toBe(true);
expect(hasMetadataFormOverlay('dashboard', 'en-US')).toBe(false);
});

it('leaves the form untouched (same object) outside zh', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'dashboard', 'en-US')).toBe(form);
});

it('leaves a type the overlay does not carry untouched', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'report', 'zh-CN')).toBe(form);
});

it('translates section headings — addressed by the SLUGGED label, not the label text', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const labels = sections(zh).map((s) => s.label);
expect(labels).toContain('布局'); // 'Layout' → section key `layout`
expect(labels).toContain('筛选'); // 'Filters' → `filters`
expect(labels).not.toContain('Layout');
const layout = sections(zh).find((s) => s.label === '布局');
expect(layout?.description).toBe('栅格尺寸与刷新频率。');
expect(layout?.description).not.toMatch(/refresh cadence/);
});

it('translates the layout field labels and hints the card named', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
expect(fieldByName(zh, 'columns')).toMatchObject({ label: '列数', helpText: '栅格列数(默认 12)' });
expect(fieldByName(zh, 'refreshInterval')?.label).toBe('自动刷新');
expect(fieldByName(zh, 'header')?.label).toBe('页眉');
});

it('drops the "Tailwind units" developer vocabulary rather than transliterating it', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const gap = fieldByName(zh, 'gap');
expect(gap?.label).toBe('间距');
expect(gap?.helpText).not.toMatch(/Tailwind/i);
// The English source still says it — that copy is the spec's to fix, and
// this overlay deliberately does not rewrite the producer's own text.
expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(/Tailwind/i);
});

it('synthesizes ALL of the `header` composite’s sub-rows — a partial list would hide the rest', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const header = fieldByName(zh, 'header');
expect(header?.type).toBe('composite');
const children = (header?.fields ?? []).map((f) => f.field);
// Exactly `DashboardHeaderSchema`'s three properties. SchemaForm prefers a
// declared `fields` array over the schema-derived one, so anything absent
// here would disappear from the panel.
expect(children.sort()).toEqual(['actions', 'showDescription', 'showTitle']);
const byName = new Map((header!.fields ?? []).map((f) => [f.field, f]));
expect(byName.get('showTitle')?.label).toBe('显示标题');
expect(byName.get('showTitle')?.helpText).toBe('在页眉中显示仪表板名称');
expect(byName.get('showDescription')?.label).toBe('显示描述');
expect(byName.get('actions')?.label).toBe('操作按钮');
});
});

describe('getDashboardForm — the overlay reaches the panel, and caches per locale', () => {
it('serves the spec’s English by default and Chinese to a zh console', () => {
const en = getDashboardForm('en-US');
const zh = getDashboardForm('zh-CN');
expect(sections(en).map((s) => s.label)).toContain('Layout');
expect(sections(zh).map((s) => s.label)).toContain('布局');
});

it('does not serve one locale’s copy to another (the single-slot cache bug)', () => {
// Order matters: ask for zh first, then en. A single memo slot would hand
// the Chinese form to the English console.
expect(sections(getDashboardForm('zh-CN')).map((s) => s.label)).toContain('布局');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).toContain('Layout');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).not.toContain('布局');
});

it('is stable per locale', () => {
expect(getDashboardForm('zh-CN')).toBe(getDashboardForm('zh-CN'));
});

it('still prunes the fields the curated inspector owns', () => {
const zh = getDashboardForm('zh-CN');
for (const owned of ['name', 'label', 'description', 'widgets']) {
expect(fieldByName(zh, owned)).toBeUndefined();
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
27 changes: 27 additions & 0 deletions .changeset/studio-jargon-and-tool-card-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
'@object-ui/plugin-chatbot': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

Studio workbench and AI tool cards speak the author's language (objectui#7254)

- The Interfaces breadcrumb, canvas caption and navigation rail show the
metadata label plus a translated kind; the internal `type · name` pair moves
to the tooltip. An unlabelled nav leaf now falls back to its object name
instead of rendering an empty row.
- The Studio top-bar package switcher reads the package's human name from
either position the packages endpoint serves it in, instead of degrading a
registry-shaped entry to its reverse-domain id.
- The dashboard property panel is localized: the spec's authoring form is
overlaid through the platform's own `metadataForms.<type>` convention, so
section headings, field labels, hints and the `header` composite's sub-fields
render in Chinese (developer vocabulary such as "Tailwind units" is replaced
with something an author can act on, not transliterated).
- AI tool cards: tool titles resolve through `chatbot.tool.<name>` (all thirty
platform-provided tools, ten locale packs), the header status badge is
localized, and the plan count strip is a real plural family instead of an
English `+ "s"` concatenation.
- The tool card's header badge and its body badge now come from one producer:
a proposal that has been confirmed, built or published no longer keeps a
header reading "Awaiting Approval".
31 changes: 26 additions & 5 deletions packages/app-shell/src/views/metadata-admin/dashboard-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,8 @@
import { z } from 'zod';
import { DashboardSchema, dashboardForm as specDashboardForm } from '@objectstack/spec/ui';
import type { FormViewSpec } from './SchemaForm.js';
import type { SupportedLocale } from './i18n.js';
import { localizeMetadataForm } from './metadata-form-i18n.js';

type JsonSchema = Record<string, any>;

Expand DownExpand Up@@ -54,16 +56,28 @@ export function getDashboardSchema(): JsonSchema | undefined {
return _dashboardDocSchema;
}

let _dashboardForm: FormViewSpec | undefined;
/**
* Per-locale, because the form now carries LOCALIZED copy (objectui#7254) and
* a single slot would have served whichever locale asked first to everyone
* after it — a cache that is right until someone switches language.
*/
const _dashboardForm = new Map<string, FormViewSpec | undefined>();

/**
* The canonical authoring FormView, with the fields the curated inspector
* owns directly (widgets / label / description / name) pruned from every
* section so they are not double-rendered. Everything else — layout,
* filters, performance — flows through verbatim from the spec.
*
* The spec authors these strings in English. `localizeMetadataForm` overlays
* the active locale on top (objectui#7254) through the platform's own
* `metadataForms.<type>` convention, so the panel speaks the same language as
* the Studio around it. A locale the overlay does not carry gets the spec's
* English, exactly as before.
*/
export function getDashboardForm(): FormViewSpec | undefined {
if (_dashboardForm) return _dashboardForm;
export function getDashboardForm(locale?: SupportedLocale | string): FormViewSpec | undefined {
const cacheKey = String(locale ?? '');
if (_dashboardForm.has(cacheKey)) return _dashboardForm.get(cacheKey);
if (!specDashboardForm || typeof specDashboardForm !== 'object') return undefined;
try {
const clone = JSON.parse(JSON.stringify(specDashboardForm)) as FormViewSpec;
Expand All@@ -77,12 +91,19 @@ export function getDashboardForm(): FormViewSpec | undefined {
clone.sections = (clone.sections ?? []).filter(
(s: any) => (s.fields ?? []).length > 0,
);
_dashboardForm = clone;
// Localize AFTER pruning: the overlay only has to reach what renders, and
// pruning cannot drop a section the overlay just renamed.
const localized = localizeMetadataForm(
clone as unknown as Record<string, unknown>,
'dashboard',
locale,
) as unknown as FormViewSpec;
_dashboardForm.set(cacheKey, localized);
return localized;
} catch (err) {
if (typeof console !== 'undefined') {
console.warn('[dashboard-schema] failed to prepare dashboardForm from spec', err);
}
return undefined;
}
return _dashboardForm;
}
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1737,6 +1737,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.nav.noObjects': 'This package has no objects yet — create one in the Data pillar first.',
// Interfaces pillar
'engine.studio.if.pickLeft': 'Select a menu item on the left',
// objectui#7254 — the tooltip that keeps the DEVELOPER identity reachable
// after the breadcrumb/caption stopped printing it beside a localized
// label. Prefixed rather than bare: `dashboard · customer_dashboard` on
// its own does not say what it is, and it is also what the rail item's
// own tooltip carries — three elements sharing one tooltip string is a
// page nothing (a person or a test) can address unambiguously.
'engine.studio.if.internalId': 'Internal id',
'engine.studio.if.navHeading': '{app} · Navigation',
'engine.studio.if.editNavTitle': 'Edit navigation (drag to reorder / rename / add-remove)',
'engine.studio.if.doneEditTitle': 'Done editing',
Expand DownExpand Up@@ -3623,6 +3630,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.nav.noObjects': '这个软件包还没有对象 — 先到 Data 支柱创建。',
// Interfaces pillar
'engine.studio.if.pickLeft': '从左侧选择一个菜单项',
'engine.studio.if.internalId': '内部标识',
'engine.studio.if.navHeading': '{app} · 导航',
'engine.studio.if.editNavTitle': '编辑导航(拖拽排序 / 重命名 / 增删)',
'engine.studio.if.doneEditTitle': '完成编辑',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,7 +130,9 @@ export function DashboardDefaultInspector({
() =>
mergeServerFields({
bundledSchema: getDashboardSchema(),
bundledForm: getDashboardForm(),
// objectui#7254 — the spec form is English; the locale overlay lives
// inside `getDashboardForm` so every consumer of it gets the same copy.
bundledForm: getDashboardForm(locale),
serverSchema,
excludeFields: DASHBOARD_CURATED_FIELDS,
sectionTitle: t('engine.inspector.moreFields', locale),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7254 — the dashboard property panel was an all-English island
* inside a Chinese Studio.
*
* The panel is spec-driven on purpose (`dashboardForm` from
* `@objectstack/spec/ui` fed straight into SchemaForm), and the spec authors
* its copy in English, so every section heading, field label and hint reached
* the author untranslated — including "Grid gap (Tailwind units)", a unit only
* a developer can act on.
*
* These pins assert the OVERLAY, and specifically the two things a hand-rolled
* walker would have got wrong and a shape-only test would not have noticed:
* the section key is the SLUGGED label (not the label text), and a `composite`
* field's sub-rows are synthesized from the bundle — completely, or the ones
* left out vanish from the form.
*/
import { describe, expect, it } from 'vitest';
import { dashboardForm } from '@objectstack/spec/ui';
import { localizeMetadataForm, hasMetadataFormOverlay } from './metadata-form-i18n';
import { getDashboardForm } from './dashboard-schema';

/** The two form shapes these assertions read. `unknown` everywhere else. */
interface FormField {
field?: string;
type?: string;
label?: string;
helpText?: string;
fields?: FormField[];
}
interface Section {
label?: string;
description?: string;
fields?: FormField[];
}

function sections(form: unknown): Section[] {
return ((form as { sections?: Section[] } | undefined)?.sections ?? []) as Section[];
}

function fieldByName(form: unknown, name: string): FormField | undefined {
for (const s of sections(form)) {
for (const f of s.fields ?? []) {
if (f?.field === name) return f;
}
}
return undefined;
}

describe('localizeMetadataForm — the spec form, in the author’s language', () => {
it('has an overlay for `dashboard` at zh and none at en', () => {
expect(hasMetadataFormOverlay('dashboard', 'zh-CN')).toBe(true);
expect(hasMetadataFormOverlay('dashboard', 'en-US')).toBe(false);
});

it('leaves the form untouched (same object) outside zh', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'dashboard', 'en-US')).toBe(form);
});

it('leaves a type the overlay does not carry untouched', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'report', 'zh-CN')).toBe(form);
});

it('translates section headings — addressed by the SLUGGED label, not the label text', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const labels = sections(zh).map((s) => s.label);
expect(labels).toContain('布局'); // 'Layout' → section key `layout`
expect(labels).toContain('筛选'); // 'Filters' → `filters`
expect(labels).not.toContain('Layout');
const layout = sections(zh).find((s) => s.label === '布局');
expect(layout?.description).toBe('栅格尺寸与刷新频率。');
expect(layout?.description).not.toMatch(/refresh cadence/);
});

it('translates the layout field labels and hints the card named', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
expect(fieldByName(zh, 'columns')).toMatchObject({ label: '列数', helpText: '栅格列数(默认 12)' });
expect(fieldByName(zh, 'refreshInterval')?.label).toBe('自动刷新');
expect(fieldByName(zh, 'header')?.label).toBe('页眉');
});

it('drops the "Tailwind units" developer vocabulary rather than transliterating it', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const gap = fieldByName(zh, 'gap');
expect(gap?.label).toBe('间距');
expect(gap?.helpText).not.toMatch(/Tailwind/i);
// The English source still says it — that copy is the spec's to fix, and
// this overlay deliberately does not rewrite the producer's own text.
expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(/Tailwind/i);
});

it('synthesizes ALL of the `header` composite’s sub-rows — a partial list would hide the rest', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const header = fieldByName(zh, 'header');
expect(header?.type).toBe('composite');
const children = (header?.fields ?? []).map((f) => f.field);
// Exactly `DashboardHeaderSchema`'s three properties. SchemaForm prefers a
// declared `fields` array over the schema-derived one, so anything absent
// here would disappear from the panel.
expect(children.sort()).toEqual(['actions', 'showDescription', 'showTitle']);
const byName = new Map((header!.fields ?? []).map((f) => [f.field, f]));
expect(byName.get('showTitle')?.label).toBe('显示标题');
expect(byName.get('showTitle')?.helpText).toBe('在页眉中显示仪表板名称');
expect(byName.get('showDescription')?.label).toBe('显示描述');
expect(byName.get('actions')?.label).toBe('操作按钮');
});
});

describe('getDashboardForm — the overlay reaches the panel, and caches per locale', () => {
it('serves the spec’s English by default and Chinese to a zh console', () => {
const en = getDashboardForm('en-US');
const zh = getDashboardForm('zh-CN');
expect(sections(en).map((s) => s.label)).toContain('Layout');
expect(sections(zh).map((s) => s.label)).toContain('布局');
});

it('does not serve one locale’s copy to another (the single-slot cache bug)', () => {
// Order matters: ask for zh first, then en. A single memo slot would hand
// the Chinese form to the English console.
expect(sections(getDashboardForm('zh-CN')).map((s) => s.label)).toContain('布局');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).toContain('Layout');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).not.toContain('布局');
});

it('is stable per locale', () => {
expect(getDashboardForm('zh-CN')).toBe(getDashboardForm('zh-CN'));
});

it('still prunes the fields the curated inspector owns', () => {
const zh = getDashboardForm('zh-CN');
for (const owned of ['name', 'label', 'description', 'widgets']) {
expect(fieldByName(zh, owned)).toBeUndefined();
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
27 changes: 27 additions & 0 deletions .changeset/studio-jargon-and-tool-card-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
'@object-ui/plugin-chatbot': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

Studio workbench and AI tool cards speak the author's language (objectui#7254)

- The Interfaces breadcrumb, canvas caption and navigation rail show the
metadata label plus a translated kind; the internal `type · name` pair moves
to the tooltip. An unlabelled nav leaf now falls back to its object name
instead of rendering an empty row.
- The Studio top-bar package switcher reads the package's human name from
either position the packages endpoint serves it in, instead of degrading a
registry-shaped entry to its reverse-domain id.
- The dashboard property panel is localized: the spec's authoring form is
overlaid through the platform's own `metadataForms.<type>` convention, so
section headings, field labels, hints and the `header` composite's sub-fields
render in Chinese (developer vocabulary such as "Tailwind units" is replaced
with something an author can act on, not transliterated).
- AI tool cards: tool titles resolve through `chatbot.tool.<name>` (all thirty
platform-provided tools, ten locale packs), the header status badge is
localized, and the plan count strip is a real plural family instead of an
English `+ "s"` concatenation.
- The tool card's header badge and its body badge now come from one producer:
a proposal that has been confirmed, built or published no longer keeps a
header reading "Awaiting Approval".
31 changes: 26 additions & 5 deletions packages/app-shell/src/views/metadata-admin/dashboard-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,8 @@
import { z } from 'zod';
import { DashboardSchema, dashboardForm as specDashboardForm } from '@objectstack/spec/ui';
import type { FormViewSpec } from './SchemaForm.js';
import type { SupportedLocale } from './i18n.js';
import { localizeMetadataForm } from './metadata-form-i18n.js';

type JsonSchema = Record<string, any>;

Expand DownExpand Up@@ -54,16 +56,28 @@ export function getDashboardSchema(): JsonSchema | undefined {
return _dashboardDocSchema;
}

let _dashboardForm: FormViewSpec | undefined;
/**
* Per-locale, because the form now carries LOCALIZED copy (objectui#7254) and
* a single slot would have served whichever locale asked first to everyone
* after it — a cache that is right until someone switches language.
*/
const _dashboardForm = new Map<string, FormViewSpec | undefined>();

/**
* The canonical authoring FormView, with the fields the curated inspector
* owns directly (widgets / label / description / name) pruned from every
* section so they are not double-rendered. Everything else — layout,
* filters, performance — flows through verbatim from the spec.
*
* The spec authors these strings in English. `localizeMetadataForm` overlays
* the active locale on top (objectui#7254) through the platform's own
* `metadataForms.<type>` convention, so the panel speaks the same language as
* the Studio around it. A locale the overlay does not carry gets the spec's
* English, exactly as before.
*/
export function getDashboardForm(): FormViewSpec | undefined {
if (_dashboardForm) return _dashboardForm;
export function getDashboardForm(locale?: SupportedLocale | string): FormViewSpec | undefined {
const cacheKey = String(locale ?? '');
if (_dashboardForm.has(cacheKey)) return _dashboardForm.get(cacheKey);
if (!specDashboardForm || typeof specDashboardForm !== 'object') return undefined;
try {
const clone = JSON.parse(JSON.stringify(specDashboardForm)) as FormViewSpec;
Expand All@@ -77,12 +91,19 @@ export function getDashboardForm(): FormViewSpec | undefined {
clone.sections = (clone.sections ?? []).filter(
(s: any) => (s.fields ?? []).length > 0,
);
_dashboardForm = clone;
// Localize AFTER pruning: the overlay only has to reach what renders, and
// pruning cannot drop a section the overlay just renamed.
const localized = localizeMetadataForm(
clone as unknown as Record<string, unknown>,
'dashboard',
locale,
) as unknown as FormViewSpec;
_dashboardForm.set(cacheKey, localized);
return localized;
} catch (err) {
if (typeof console !== 'undefined') {
console.warn('[dashboard-schema] failed to prepare dashboardForm from spec', err);
}
return undefined;
}
return _dashboardForm;
}
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1737,6 +1737,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.nav.noObjects': 'This package has no objects yet — create one in the Data pillar first.',
// Interfaces pillar
'engine.studio.if.pickLeft': 'Select a menu item on the left',
// objectui#7254 — the tooltip that keeps the DEVELOPER identity reachable
// after the breadcrumb/caption stopped printing it beside a localized
// label. Prefixed rather than bare: `dashboard · customer_dashboard` on
// its own does not say what it is, and it is also what the rail item's
// own tooltip carries — three elements sharing one tooltip string is a
// page nothing (a person or a test) can address unambiguously.
'engine.studio.if.internalId': 'Internal id',
'engine.studio.if.navHeading': '{app} · Navigation',
'engine.studio.if.editNavTitle': 'Edit navigation (drag to reorder / rename / add-remove)',
'engine.studio.if.doneEditTitle': 'Done editing',
Expand DownExpand Up@@ -3623,6 +3630,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.nav.noObjects': '这个软件包还没有对象 — 先到 Data 支柱创建。',
// Interfaces pillar
'engine.studio.if.pickLeft': '从左侧选择一个菜单项',
'engine.studio.if.internalId': '内部标识',
'engine.studio.if.navHeading': '{app} · 导航',
'engine.studio.if.editNavTitle': '编辑导航(拖拽排序 / 重命名 / 增删)',
'engine.studio.if.doneEditTitle': '完成编辑',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,7 +130,9 @@ export function DashboardDefaultInspector({
() =>
mergeServerFields({
bundledSchema: getDashboardSchema(),
bundledForm: getDashboardForm(),
// objectui#7254 — the spec form is English; the locale overlay lives
// inside `getDashboardForm` so every consumer of it gets the same copy.
bundledForm: getDashboardForm(locale),
serverSchema,
excludeFields: DASHBOARD_CURATED_FIELDS,
sectionTitle: t('engine.inspector.moreFields', locale),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7254 — the dashboard property panel was an all-English island
* inside a Chinese Studio.
*
* The panel is spec-driven on purpose (`dashboardForm` from
* `@objectstack/spec/ui` fed straight into SchemaForm), and the spec authors
* its copy in English, so every section heading, field label and hint reached
* the author untranslated — including "Grid gap (Tailwind units)", a unit only
* a developer can act on.
*
* These pins assert the OVERLAY, and specifically the two things a hand-rolled
* walker would have got wrong and a shape-only test would not have noticed:
* the section key is the SLUGGED label (not the label text), and a `composite`
* field's sub-rows are synthesized from the bundle — completely, or the ones
* left out vanish from the form.
*/
import { describe, expect, it } from 'vitest';
import { dashboardForm } from '@objectstack/spec/ui';
import { localizeMetadataForm, hasMetadataFormOverlay } from './metadata-form-i18n';
import { getDashboardForm } from './dashboard-schema';

/** The two form shapes these assertions read. `unknown` everywhere else. */
interface FormField {
field?: string;
type?: string;
label?: string;
helpText?: string;
fields?: FormField[];
}
interface Section {
label?: string;
description?: string;
fields?: FormField[];
}

function sections(form: unknown): Section[] {
return ((form as { sections?: Section[] } | undefined)?.sections ?? []) as Section[];
}

function fieldByName(form: unknown, name: string): FormField | undefined {
for (const s of sections(form)) {
for (const f of s.fields ?? []) {
if (f?.field === name) return f;
}
}
return undefined;
}

describe('localizeMetadataForm — the spec form, in the author’s language', () => {
it('has an overlay for `dashboard` at zh and none at en', () => {
expect(hasMetadataFormOverlay('dashboard', 'zh-CN')).toBe(true);
expect(hasMetadataFormOverlay('dashboard', 'en-US')).toBe(false);
});

it('leaves the form untouched (same object) outside zh', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'dashboard', 'en-US')).toBe(form);
});

it('leaves a type the overlay does not carry untouched', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'report', 'zh-CN')).toBe(form);
});

it('translates section headings — addressed by the SLUGGED label, not the label text', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const labels = sections(zh).map((s) => s.label);
expect(labels).toContain('布局'); // 'Layout' → section key `layout`
expect(labels).toContain('筛选'); // 'Filters' → `filters`
expect(labels).not.toContain('Layout');
const layout = sections(zh).find((s) => s.label === '布局');
expect(layout?.description).toBe('栅格尺寸与刷新频率。');
expect(layout?.description).not.toMatch(/refresh cadence/);
});

it('translates the layout field labels and hints the card named', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
expect(fieldByName(zh, 'columns')).toMatchObject({ label: '列数', helpText: '栅格列数(默认 12)' });
expect(fieldByName(zh, 'refreshInterval')?.label).toBe('自动刷新');
expect(fieldByName(zh, 'header')?.label).toBe('页眉');
});

it('drops the "Tailwind units" developer vocabulary rather than transliterating it', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const gap = fieldByName(zh, 'gap');
expect(gap?.label).toBe('间距');
expect(gap?.helpText).not.toMatch(/Tailwind/i);
// The English source still says it — that copy is the spec's to fix, and
// this overlay deliberately does not rewrite the producer's own text.
expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(/Tailwind/i);
});

it('synthesizes ALL of the `header` composite’s sub-rows — a partial list would hide the rest', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const header = fieldByName(zh, 'header');
expect(header?.type).toBe('composite');
const children = (header?.fields ?? []).map((f) => f.field);
// Exactly `DashboardHeaderSchema`'s three properties. SchemaForm prefers a
// declared `fields` array over the schema-derived one, so anything absent
// here would disappear from the panel.
expect(children.sort()).toEqual(['actions', 'showDescription', 'showTitle']);
const byName = new Map((header!.fields ?? []).map((f) => [f.field, f]));
expect(byName.get('showTitle')?.label).toBe('显示标题');
expect(byName.get('showTitle')?.helpText).toBe('在页眉中显示仪表板名称');
expect(byName.get('showDescription')?.label).toBe('显示描述');
expect(byName.get('actions')?.label).toBe('操作按钮');
});
});

describe('getDashboardForm — the overlay reaches the panel, and caches per locale', () => {
it('serves the spec’s English by default and Chinese to a zh console', () => {
const en = getDashboardForm('en-US');
const zh = getDashboardForm('zh-CN');
expect(sections(en).map((s) => s.label)).toContain('Layout');
expect(sections(zh).map((s) => s.label)).toContain('布局');
});

it('does not serve one locale’s copy to another (the single-slot cache bug)', () => {
// Order matters: ask for zh first, then en. A single memo slot would hand
// the Chinese form to the English console.
expect(sections(getDashboardForm('zh-CN')).map((s) => s.label)).toContain('布局');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).toContain('Layout');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).not.toContain('布局');
});

it('is stable per locale', () => {
expect(getDashboardForm('zh-CN')).toBe(getDashboardForm('zh-CN'));
});

it('still prunes the fields the curated inspector owns', () => {
const zh = getDashboardForm('zh-CN');
for (const owned of ['name', 'label', 'description', 'widgets']) {
expect(fieldByName(zh, owned)).toBeUndefined();
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
27 changes: 27 additions & 0 deletions .changeset/studio-jargon-and-tool-card-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
'@object-ui/plugin-chatbot': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

Studio workbench and AI tool cards speak the author's language (objectui#7254)

- The Interfaces breadcrumb, canvas caption and navigation rail show the
metadata label plus a translated kind; the internal `type · name` pair moves
to the tooltip. An unlabelled nav leaf now falls back to its object name
instead of rendering an empty row.
- The Studio top-bar package switcher reads the package's human name from
either position the packages endpoint serves it in, instead of degrading a
registry-shaped entry to its reverse-domain id.
- The dashboard property panel is localized: the spec's authoring form is
overlaid through the platform's own `metadataForms.<type>` convention, so
section headings, field labels, hints and the `header` composite's sub-fields
render in Chinese (developer vocabulary such as "Tailwind units" is replaced
with something an author can act on, not transliterated).
- AI tool cards: tool titles resolve through `chatbot.tool.<name>` (all thirty
platform-provided tools, ten locale packs), the header status badge is
localized, and the plan count strip is a real plural family instead of an
English `+ "s"` concatenation.
- The tool card's header badge and its body badge now come from one producer:
a proposal that has been confirmed, built or published no longer keeps a
header reading "Awaiting Approval".
31 changes: 26 additions & 5 deletions packages/app-shell/src/views/metadata-admin/dashboard-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,8 @@
import { z } from 'zod';
import { DashboardSchema, dashboardForm as specDashboardForm } from '@objectstack/spec/ui';
import type { FormViewSpec } from './SchemaForm.js';
import type { SupportedLocale } from './i18n.js';
import { localizeMetadataForm } from './metadata-form-i18n.js';

type JsonSchema = Record<string, any>;

Expand DownExpand Up@@ -54,16 +56,28 @@ export function getDashboardSchema(): JsonSchema | undefined {
return _dashboardDocSchema;
}

let _dashboardForm: FormViewSpec | undefined;
/**
* Per-locale, because the form now carries LOCALIZED copy (objectui#7254) and
* a single slot would have served whichever locale asked first to everyone
* after it — a cache that is right until someone switches language.
*/
const _dashboardForm = new Map<string, FormViewSpec | undefined>();

/**
* The canonical authoring FormView, with the fields the curated inspector
* owns directly (widgets / label / description / name) pruned from every
* section so they are not double-rendered. Everything else — layout,
* filters, performance — flows through verbatim from the spec.
*
* The spec authors these strings in English. `localizeMetadataForm` overlays
* the active locale on top (objectui#7254) through the platform's own
* `metadataForms.<type>` convention, so the panel speaks the same language as
* the Studio around it. A locale the overlay does not carry gets the spec's
* English, exactly as before.
*/
export function getDashboardForm(): FormViewSpec | undefined {
if (_dashboardForm) return _dashboardForm;
export function getDashboardForm(locale?: SupportedLocale | string): FormViewSpec | undefined {
const cacheKey = String(locale ?? '');
if (_dashboardForm.has(cacheKey)) return _dashboardForm.get(cacheKey);
if (!specDashboardForm || typeof specDashboardForm !== 'object') return undefined;
try {
const clone = JSON.parse(JSON.stringify(specDashboardForm)) as FormViewSpec;
Expand All@@ -77,12 +91,19 @@ export function getDashboardForm(): FormViewSpec | undefined {
clone.sections = (clone.sections ?? []).filter(
(s: any) => (s.fields ?? []).length > 0,
);
_dashboardForm = clone;
// Localize AFTER pruning: the overlay only has to reach what renders, and
// pruning cannot drop a section the overlay just renamed.
const localized = localizeMetadataForm(
clone as unknown as Record<string, unknown>,
'dashboard',
locale,
) as unknown as FormViewSpec;
_dashboardForm.set(cacheKey, localized);
return localized;
} catch (err) {
if (typeof console !== 'undefined') {
console.warn('[dashboard-schema] failed to prepare dashboardForm from spec', err);
}
return undefined;
}
return _dashboardForm;
}
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1737,6 +1737,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.nav.noObjects': 'This package has no objects yet — create one in the Data pillar first.',
// Interfaces pillar
'engine.studio.if.pickLeft': 'Select a menu item on the left',
// objectui#7254 — the tooltip that keeps the DEVELOPER identity reachable
// after the breadcrumb/caption stopped printing it beside a localized
// label. Prefixed rather than bare: `dashboard · customer_dashboard` on
// its own does not say what it is, and it is also what the rail item's
// own tooltip carries — three elements sharing one tooltip string is a
// page nothing (a person or a test) can address unambiguously.
'engine.studio.if.internalId': 'Internal id',
'engine.studio.if.navHeading': '{app} · Navigation',
'engine.studio.if.editNavTitle': 'Edit navigation (drag to reorder / rename / add-remove)',
'engine.studio.if.doneEditTitle': 'Done editing',
Expand DownExpand Up@@ -3623,6 +3630,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.nav.noObjects': '这个软件包还没有对象 — 先到 Data 支柱创建。',
// Interfaces pillar
'engine.studio.if.pickLeft': '从左侧选择一个菜单项',
'engine.studio.if.internalId': '内部标识',
'engine.studio.if.navHeading': '{app} · 导航',
'engine.studio.if.editNavTitle': '编辑导航(拖拽排序 / 重命名 / 增删)',
'engine.studio.if.doneEditTitle': '完成编辑',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,7 +130,9 @@ export function DashboardDefaultInspector({
() =>
mergeServerFields({
bundledSchema: getDashboardSchema(),
bundledForm: getDashboardForm(),
// objectui#7254 — the spec form is English; the locale overlay lives
// inside `getDashboardForm` so every consumer of it gets the same copy.
bundledForm: getDashboardForm(locale),
serverSchema,
excludeFields: DASHBOARD_CURATED_FIELDS,
sectionTitle: t('engine.inspector.moreFields', locale),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7254 — the dashboard property panel was an all-English island
* inside a Chinese Studio.
*
* The panel is spec-driven on purpose (`dashboardForm` from
* `@objectstack/spec/ui` fed straight into SchemaForm), and the spec authors
* its copy in English, so every section heading, field label and hint reached
* the author untranslated — including "Grid gap (Tailwind units)", a unit only
* a developer can act on.
*
* These pins assert the OVERLAY, and specifically the two things a hand-rolled
* walker would have got wrong and a shape-only test would not have noticed:
* the section key is the SLUGGED label (not the label text), and a `composite`
* field's sub-rows are synthesized from the bundle — completely, or the ones
* left out vanish from the form.
*/
import { describe, expect, it } from 'vitest';
import { dashboardForm } from '@objectstack/spec/ui';
import { localizeMetadataForm, hasMetadataFormOverlay } from './metadata-form-i18n';
import { getDashboardForm } from './dashboard-schema';

/** The two form shapes these assertions read. `unknown` everywhere else. */
interface FormField {
field?: string;
type?: string;
label?: string;
helpText?: string;
fields?: FormField[];
}
interface Section {
label?: string;
description?: string;
fields?: FormField[];
}

function sections(form: unknown): Section[] {
return ((form as { sections?: Section[] } | undefined)?.sections ?? []) as Section[];
}

function fieldByName(form: unknown, name: string): FormField | undefined {
for (const s of sections(form)) {
for (const f of s.fields ?? []) {
if (f?.field === name) return f;
}
}
return undefined;
}

describe('localizeMetadataForm — the spec form, in the author’s language', () => {
it('has an overlay for `dashboard` at zh and none at en', () => {
expect(hasMetadataFormOverlay('dashboard', 'zh-CN')).toBe(true);
expect(hasMetadataFormOverlay('dashboard', 'en-US')).toBe(false);
});

it('leaves the form untouched (same object) outside zh', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'dashboard', 'en-US')).toBe(form);
});

it('leaves a type the overlay does not carry untouched', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'report', 'zh-CN')).toBe(form);
});

it('translates section headings — addressed by the SLUGGED label, not the label text', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const labels = sections(zh).map((s) => s.label);
expect(labels).toContain('布局'); // 'Layout' → section key `layout`
expect(labels).toContain('筛选'); // 'Filters' → `filters`
expect(labels).not.toContain('Layout');
const layout = sections(zh).find((s) => s.label === '布局');
expect(layout?.description).toBe('栅格尺寸与刷新频率。');
expect(layout?.description).not.toMatch(/refresh cadence/);
});

it('translates the layout field labels and hints the card named', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
expect(fieldByName(zh, 'columns')).toMatchObject({ label: '列数', helpText: '栅格列数(默认 12)' });
expect(fieldByName(zh, 'refreshInterval')?.label).toBe('自动刷新');
expect(fieldByName(zh, 'header')?.label).toBe('页眉');
});

it('drops the "Tailwind units" developer vocabulary rather than transliterating it', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const gap = fieldByName(zh, 'gap');
expect(gap?.label).toBe('间距');
expect(gap?.helpText).not.toMatch(/Tailwind/i);
// The English source still says it — that copy is the spec's to fix, and
// this overlay deliberately does not rewrite the producer's own text.
expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(/Tailwind/i);
});

it('synthesizes ALL of the `header` composite’s sub-rows — a partial list would hide the rest', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const header = fieldByName(zh, 'header');
expect(header?.type).toBe('composite');
const children = (header?.fields ?? []).map((f) => f.field);
// Exactly `DashboardHeaderSchema`'s three properties. SchemaForm prefers a
// declared `fields` array over the schema-derived one, so anything absent
// here would disappear from the panel.
expect(children.sort()).toEqual(['actions', 'showDescription', 'showTitle']);
const byName = new Map((header!.fields ?? []).map((f) => [f.field, f]));
expect(byName.get('showTitle')?.label).toBe('显示标题');
expect(byName.get('showTitle')?.helpText).toBe('在页眉中显示仪表板名称');
expect(byName.get('showDescription')?.label).toBe('显示描述');
expect(byName.get('actions')?.label).toBe('操作按钮');
});
});

describe('getDashboardForm — the overlay reaches the panel, and caches per locale', () => {
it('serves the spec’s English by default and Chinese to a zh console', () => {
const en = getDashboardForm('en-US');
const zh = getDashboardForm('zh-CN');
expect(sections(en).map((s) => s.label)).toContain('Layout');
expect(sections(zh).map((s) => s.label)).toContain('布局');
});

it('does not serve one locale’s copy to another (the single-slot cache bug)', () => {
// Order matters: ask for zh first, then en. A single memo slot would hand
// the Chinese form to the English console.
expect(sections(getDashboardForm('zh-CN')).map((s) => s.label)).toContain('布局');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).toContain('Layout');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).not.toContain('布局');
});

it('is stable per locale', () => {
expect(getDashboardForm('zh-CN')).toBe(getDashboardForm('zh-CN'));
});

it('still prunes the fields the curated inspector owns', () => {
const zh = getDashboardForm('zh-CN');
for (const owned of ['name', 'label', 'description', 'widgets']) {
expect(fieldByName(zh, owned)).toBeUndefined();
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
27 changes: 27 additions & 0 deletions .changeset/studio-jargon-and-tool-card-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
'@object-ui/plugin-chatbot': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

Studio workbench and AI tool cards speak the author's language (objectui#7254)

- The Interfaces breadcrumb, canvas caption and navigation rail show the
metadata label plus a translated kind; the internal `type · name` pair moves
to the tooltip. An unlabelled nav leaf now falls back to its object name
instead of rendering an empty row.
- The Studio top-bar package switcher reads the package's human name from
either position the packages endpoint serves it in, instead of degrading a
registry-shaped entry to its reverse-domain id.
- The dashboard property panel is localized: the spec's authoring form is
overlaid through the platform's own `metadataForms.<type>` convention, so
section headings, field labels, hints and the `header` composite's sub-fields
render in Chinese (developer vocabulary such as "Tailwind units" is replaced
with something an author can act on, not transliterated).
- AI tool cards: tool titles resolve through `chatbot.tool.<name>` (all thirty
platform-provided tools, ten locale packs), the header status badge is
localized, and the plan count strip is a real plural family instead of an
English `+ "s"` concatenation.
- The tool card's header badge and its body badge now come from one producer:
a proposal that has been confirmed, built or published no longer keeps a
header reading "Awaiting Approval".
31 changes: 26 additions & 5 deletions packages/app-shell/src/views/metadata-admin/dashboard-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,8 @@
import { z } from 'zod';
import { DashboardSchema, dashboardForm as specDashboardForm } from '@objectstack/spec/ui';
import type { FormViewSpec } from './SchemaForm.js';
import type { SupportedLocale } from './i18n.js';
import { localizeMetadataForm } from './metadata-form-i18n.js';

type JsonSchema = Record<string, any>;

Expand DownExpand Up@@ -54,16 +56,28 @@ export function getDashboardSchema(): JsonSchema | undefined {
return _dashboardDocSchema;
}

let _dashboardForm: FormViewSpec | undefined;
/**
* Per-locale, because the form now carries LOCALIZED copy (objectui#7254) and
* a single slot would have served whichever locale asked first to everyone
* after it — a cache that is right until someone switches language.
*/
const _dashboardForm = new Map<string, FormViewSpec | undefined>();

/**
* The canonical authoring FormView, with the fields the curated inspector
* owns directly (widgets / label / description / name) pruned from every
* section so they are not double-rendered. Everything else — layout,
* filters, performance — flows through verbatim from the spec.
*
* The spec authors these strings in English. `localizeMetadataForm` overlays
* the active locale on top (objectui#7254) through the platform's own
* `metadataForms.<type>` convention, so the panel speaks the same language as
* the Studio around it. A locale the overlay does not carry gets the spec's
* English, exactly as before.
*/
export function getDashboardForm(): FormViewSpec | undefined {
if (_dashboardForm) return _dashboardForm;
export function getDashboardForm(locale?: SupportedLocale | string): FormViewSpec | undefined {
const cacheKey = String(locale ?? '');
if (_dashboardForm.has(cacheKey)) return _dashboardForm.get(cacheKey);
if (!specDashboardForm || typeof specDashboardForm !== 'object') return undefined;
try {
const clone = JSON.parse(JSON.stringify(specDashboardForm)) as FormViewSpec;
Expand All@@ -77,12 +91,19 @@ export function getDashboardForm(): FormViewSpec | undefined {
clone.sections = (clone.sections ?? []).filter(
(s: any) => (s.fields ?? []).length > 0,
);
_dashboardForm = clone;
// Localize AFTER pruning: the overlay only has to reach what renders, and
// pruning cannot drop a section the overlay just renamed.
const localized = localizeMetadataForm(
clone as unknown as Record<string, unknown>,
'dashboard',
locale,
) as unknown as FormViewSpec;
_dashboardForm.set(cacheKey, localized);
return localized;
} catch (err) {
if (typeof console !== 'undefined') {
console.warn('[dashboard-schema] failed to prepare dashboardForm from spec', err);
}
return undefined;
}
return _dashboardForm;
}
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1737,6 +1737,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.nav.noObjects': 'This package has no objects yet — create one in the Data pillar first.',
// Interfaces pillar
'engine.studio.if.pickLeft': 'Select a menu item on the left',
// objectui#7254 — the tooltip that keeps the DEVELOPER identity reachable
// after the breadcrumb/caption stopped printing it beside a localized
// label. Prefixed rather than bare: `dashboard · customer_dashboard` on
// its own does not say what it is, and it is also what the rail item's
// own tooltip carries — three elements sharing one tooltip string is a
// page nothing (a person or a test) can address unambiguously.
'engine.studio.if.internalId': 'Internal id',
'engine.studio.if.navHeading': '{app} · Navigation',
'engine.studio.if.editNavTitle': 'Edit navigation (drag to reorder / rename / add-remove)',
'engine.studio.if.doneEditTitle': 'Done editing',
Expand DownExpand Up@@ -3623,6 +3630,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.nav.noObjects': '这个软件包还没有对象 — 先到 Data 支柱创建。',
// Interfaces pillar
'engine.studio.if.pickLeft': '从左侧选择一个菜单项',
'engine.studio.if.internalId': '内部标识',
'engine.studio.if.navHeading': '{app} · 导航',
'engine.studio.if.editNavTitle': '编辑导航(拖拽排序 / 重命名 / 增删)',
'engine.studio.if.doneEditTitle': '完成编辑',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,7 +130,9 @@ export function DashboardDefaultInspector({
() =>
mergeServerFields({
bundledSchema: getDashboardSchema(),
bundledForm: getDashboardForm(),
// objectui#7254 — the spec form is English; the locale overlay lives
// inside `getDashboardForm` so every consumer of it gets the same copy.
bundledForm: getDashboardForm(locale),
serverSchema,
excludeFields: DASHBOARD_CURATED_FIELDS,
sectionTitle: t('engine.inspector.moreFields', locale),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7254 — the dashboard property panel was an all-English island
* inside a Chinese Studio.
*
* The panel is spec-driven on purpose (`dashboardForm` from
* `@objectstack/spec/ui` fed straight into SchemaForm), and the spec authors
* its copy in English, so every section heading, field label and hint reached
* the author untranslated — including "Grid gap (Tailwind units)", a unit only
* a developer can act on.
*
* These pins assert the OVERLAY, and specifically the two things a hand-rolled
* walker would have got wrong and a shape-only test would not have noticed:
* the section key is the SLUGGED label (not the label text), and a `composite`
* field's sub-rows are synthesized from the bundle — completely, or the ones
* left out vanish from the form.
*/
import { describe, expect, it } from 'vitest';
import { dashboardForm } from '@objectstack/spec/ui';
import { localizeMetadataForm, hasMetadataFormOverlay } from './metadata-form-i18n';
import { getDashboardForm } from './dashboard-schema';

/** The two form shapes these assertions read. `unknown` everywhere else. */
interface FormField {
field?: string;
type?: string;
label?: string;
helpText?: string;
fields?: FormField[];
}
interface Section {
label?: string;
description?: string;
fields?: FormField[];
}

function sections(form: unknown): Section[] {
return ((form as { sections?: Section[] } | undefined)?.sections ?? []) as Section[];
}

function fieldByName(form: unknown, name: string): FormField | undefined {
for (const s of sections(form)) {
for (const f of s.fields ?? []) {
if (f?.field === name) return f;
}
}
return undefined;
}

describe('localizeMetadataForm — the spec form, in the author’s language', () => {
it('has an overlay for `dashboard` at zh and none at en', () => {
expect(hasMetadataFormOverlay('dashboard', 'zh-CN')).toBe(true);
expect(hasMetadataFormOverlay('dashboard', 'en-US')).toBe(false);
});

it('leaves the form untouched (same object) outside zh', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'dashboard', 'en-US')).toBe(form);
});

it('leaves a type the overlay does not carry untouched', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'report', 'zh-CN')).toBe(form);
});

it('translates section headings — addressed by the SLUGGED label, not the label text', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const labels = sections(zh).map((s) => s.label);
expect(labels).toContain('布局'); // 'Layout' → section key `layout`
expect(labels).toContain('筛选'); // 'Filters' → `filters`
expect(labels).not.toContain('Layout');
const layout = sections(zh).find((s) => s.label === '布局');
expect(layout?.description).toBe('栅格尺寸与刷新频率。');
expect(layout?.description).not.toMatch(/refresh cadence/);
});

it('translates the layout field labels and hints the card named', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
expect(fieldByName(zh, 'columns')).toMatchObject({ label: '列数', helpText: '栅格列数(默认 12)' });
expect(fieldByName(zh, 'refreshInterval')?.label).toBe('自动刷新');
expect(fieldByName(zh, 'header')?.label).toBe('页眉');
});

it('drops the "Tailwind units" developer vocabulary rather than transliterating it', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const gap = fieldByName(zh, 'gap');
expect(gap?.label).toBe('间距');
expect(gap?.helpText).not.toMatch(/Tailwind/i);
// The English source still says it — that copy is the spec's to fix, and
// this overlay deliberately does not rewrite the producer's own text.
expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(/Tailwind/i);
});

it('synthesizes ALL of the `header` composite’s sub-rows — a partial list would hide the rest', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const header = fieldByName(zh, 'header');
expect(header?.type).toBe('composite');
const children = (header?.fields ?? []).map((f) => f.field);
// Exactly `DashboardHeaderSchema`'s three properties. SchemaForm prefers a
// declared `fields` array over the schema-derived one, so anything absent
// here would disappear from the panel.
expect(children.sort()).toEqual(['actions', 'showDescription', 'showTitle']);
const byName = new Map((header!.fields ?? []).map((f) => [f.field, f]));
expect(byName.get('showTitle')?.label).toBe('显示标题');
expect(byName.get('showTitle')?.helpText).toBe('在页眉中显示仪表板名称');
expect(byName.get('showDescription')?.label).toBe('显示描述');
expect(byName.get('actions')?.label).toBe('操作按钮');
});
});

describe('getDashboardForm — the overlay reaches the panel, and caches per locale', () => {
it('serves the spec’s English by default and Chinese to a zh console', () => {
const en = getDashboardForm('en-US');
const zh = getDashboardForm('zh-CN');
expect(sections(en).map((s) => s.label)).toContain('Layout');
expect(sections(zh).map((s) => s.label)).toContain('布局');
});

it('does not serve one locale’s copy to another (the single-slot cache bug)', () => {
// Order matters: ask for zh first, then en. A single memo slot would hand
// the Chinese form to the English console.
expect(sections(getDashboardForm('zh-CN')).map((s) => s.label)).toContain('布局');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).toContain('Layout');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).not.toContain('布局');
});

it('is stable per locale', () => {
expect(getDashboardForm('zh-CN')).toBe(getDashboardForm('zh-CN'));
});

it('still prunes the fields the curated inspector owns', () => {
const zh = getDashboardForm('zh-CN');
for (const owned of ['name', 'label', 'description', 'widgets']) {
expect(fieldByName(zh, owned)).toBeUndefined();
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
27 changes: 27 additions & 0 deletions .changeset/studio-jargon-and-tool-card-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
'@object-ui/plugin-chatbot': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

Studio workbench and AI tool cards speak the author's language (objectui#7254)

- The Interfaces breadcrumb, canvas caption and navigation rail show the
metadata label plus a translated kind; the internal `type · name` pair moves
to the tooltip. An unlabelled nav leaf now falls back to its object name
instead of rendering an empty row.
- The Studio top-bar package switcher reads the package's human name from
either position the packages endpoint serves it in, instead of degrading a
registry-shaped entry to its reverse-domain id.
- The dashboard property panel is localized: the spec's authoring form is
overlaid through the platform's own `metadataForms.<type>` convention, so
section headings, field labels, hints and the `header` composite's sub-fields
render in Chinese (developer vocabulary such as "Tailwind units" is replaced
with something an author can act on, not transliterated).
- AI tool cards: tool titles resolve through `chatbot.tool.<name>` (all thirty
platform-provided tools, ten locale packs), the header status badge is
localized, and the plan count strip is a real plural family instead of an
English `+ "s"` concatenation.
- The tool card's header badge and its body badge now come from one producer:
a proposal that has been confirmed, built or published no longer keeps a
header reading "Awaiting Approval".
31 changes: 26 additions & 5 deletions packages/app-shell/src/views/metadata-admin/dashboard-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,8 @@
import { z } from 'zod';
import { DashboardSchema, dashboardForm as specDashboardForm } from '@objectstack/spec/ui';
import type { FormViewSpec } from './SchemaForm.js';
import type { SupportedLocale } from './i18n.js';
import { localizeMetadataForm } from './metadata-form-i18n.js';

type JsonSchema = Record<string, any>;

Expand DownExpand Up@@ -54,16 +56,28 @@ export function getDashboardSchema(): JsonSchema | undefined {
return _dashboardDocSchema;
}

let _dashboardForm: FormViewSpec | undefined;
/**
* Per-locale, because the form now carries LOCALIZED copy (objectui#7254) and
* a single slot would have served whichever locale asked first to everyone
* after it — a cache that is right until someone switches language.
*/
const _dashboardForm = new Map<string, FormViewSpec | undefined>();

/**
* The canonical authoring FormView, with the fields the curated inspector
* owns directly (widgets / label / description / name) pruned from every
* section so they are not double-rendered. Everything else — layout,
* filters, performance — flows through verbatim from the spec.
*
* The spec authors these strings in English. `localizeMetadataForm` overlays
* the active locale on top (objectui#7254) through the platform's own
* `metadataForms.<type>` convention, so the panel speaks the same language as
* the Studio around it. A locale the overlay does not carry gets the spec's
* English, exactly as before.
*/
export function getDashboardForm(): FormViewSpec | undefined {
if (_dashboardForm) return _dashboardForm;
export function getDashboardForm(locale?: SupportedLocale | string): FormViewSpec | undefined {
const cacheKey = String(locale ?? '');
if (_dashboardForm.has(cacheKey)) return _dashboardForm.get(cacheKey);
if (!specDashboardForm || typeof specDashboardForm !== 'object') return undefined;
try {
const clone = JSON.parse(JSON.stringify(specDashboardForm)) as FormViewSpec;
Expand All@@ -77,12 +91,19 @@ export function getDashboardForm(): FormViewSpec | undefined {
clone.sections = (clone.sections ?? []).filter(
(s: any) => (s.fields ?? []).length > 0,
);
_dashboardForm = clone;
// Localize AFTER pruning: the overlay only has to reach what renders, and
// pruning cannot drop a section the overlay just renamed.
const localized = localizeMetadataForm(
clone as unknown as Record<string, unknown>,
'dashboard',
locale,
) as unknown as FormViewSpec;
_dashboardForm.set(cacheKey, localized);
return localized;
} catch (err) {
if (typeof console !== 'undefined') {
console.warn('[dashboard-schema] failed to prepare dashboardForm from spec', err);
}
return undefined;
}
return _dashboardForm;
}
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1737,6 +1737,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.nav.noObjects': 'This package has no objects yet — create one in the Data pillar first.',
// Interfaces pillar
'engine.studio.if.pickLeft': 'Select a menu item on the left',
// objectui#7254 — the tooltip that keeps the DEVELOPER identity reachable
// after the breadcrumb/caption stopped printing it beside a localized
// label. Prefixed rather than bare: `dashboard · customer_dashboard` on
// its own does not say what it is, and it is also what the rail item's
// own tooltip carries — three elements sharing one tooltip string is a
// page nothing (a person or a test) can address unambiguously.
'engine.studio.if.internalId': 'Internal id',
'engine.studio.if.navHeading': '{app} · Navigation',
'engine.studio.if.editNavTitle': 'Edit navigation (drag to reorder / rename / add-remove)',
'engine.studio.if.doneEditTitle': 'Done editing',
Expand DownExpand Up@@ -3623,6 +3630,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.nav.noObjects': '这个软件包还没有对象 — 先到 Data 支柱创建。',
// Interfaces pillar
'engine.studio.if.pickLeft': '从左侧选择一个菜单项',
'engine.studio.if.internalId': '内部标识',
'engine.studio.if.navHeading': '{app} · 导航',
'engine.studio.if.editNavTitle': '编辑导航(拖拽排序 / 重命名 / 增删)',
'engine.studio.if.doneEditTitle': '完成编辑',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,7 +130,9 @@ export function DashboardDefaultInspector({
() =>
mergeServerFields({
bundledSchema: getDashboardSchema(),
bundledForm: getDashboardForm(),
// objectui#7254 — the spec form is English; the locale overlay lives
// inside `getDashboardForm` so every consumer of it gets the same copy.
bundledForm: getDashboardForm(locale),
serverSchema,
excludeFields: DASHBOARD_CURATED_FIELDS,
sectionTitle: t('engine.inspector.moreFields', locale),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7254 — the dashboard property panel was an all-English island
* inside a Chinese Studio.
*
* The panel is spec-driven on purpose (`dashboardForm` from
* `@objectstack/spec/ui` fed straight into SchemaForm), and the spec authors
* its copy in English, so every section heading, field label and hint reached
* the author untranslated — including "Grid gap (Tailwind units)", a unit only
* a developer can act on.
*
* These pins assert the OVERLAY, and specifically the two things a hand-rolled
* walker would have got wrong and a shape-only test would not have noticed:
* the section key is the SLUGGED label (not the label text), and a `composite`
* field's sub-rows are synthesized from the bundle — completely, or the ones
* left out vanish from the form.
*/
import { describe, expect, it } from 'vitest';
import { dashboardForm } from '@objectstack/spec/ui';
import { localizeMetadataForm, hasMetadataFormOverlay } from './metadata-form-i18n';
import { getDashboardForm } from './dashboard-schema';

/** The two form shapes these assertions read. `unknown` everywhere else. */
interface FormField {
field?: string;
type?: string;
label?: string;
helpText?: string;
fields?: FormField[];
}
interface Section {
label?: string;
description?: string;
fields?: FormField[];
}

function sections(form: unknown): Section[] {
return ((form as { sections?: Section[] } | undefined)?.sections ?? []) as Section[];
}

function fieldByName(form: unknown, name: string): FormField | undefined {
for (const s of sections(form)) {
for (const f of s.fields ?? []) {
if (f?.field === name) return f;
}
}
return undefined;
}

describe('localizeMetadataForm — the spec form, in the author’s language', () => {
it('has an overlay for `dashboard` at zh and none at en', () => {
expect(hasMetadataFormOverlay('dashboard', 'zh-CN')).toBe(true);
expect(hasMetadataFormOverlay('dashboard', 'en-US')).toBe(false);
});

it('leaves the form untouched (same object) outside zh', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'dashboard', 'en-US')).toBe(form);
});

it('leaves a type the overlay does not carry untouched', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'report', 'zh-CN')).toBe(form);
});

it('translates section headings — addressed by the SLUGGED label, not the label text', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const labels = sections(zh).map((s) => s.label);
expect(labels).toContain('布局'); // 'Layout' → section key `layout`
expect(labels).toContain('筛选'); // 'Filters' → `filters`
expect(labels).not.toContain('Layout');
const layout = sections(zh).find((s) => s.label === '布局');
expect(layout?.description).toBe('栅格尺寸与刷新频率。');
expect(layout?.description).not.toMatch(/refresh cadence/);
});

it('translates the layout field labels and hints the card named', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
expect(fieldByName(zh, 'columns')).toMatchObject({ label: '列数', helpText: '栅格列数(默认 12)' });
expect(fieldByName(zh, 'refreshInterval')?.label).toBe('自动刷新');
expect(fieldByName(zh, 'header')?.label).toBe('页眉');
});

it('drops the "Tailwind units" developer vocabulary rather than transliterating it', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const gap = fieldByName(zh, 'gap');
expect(gap?.label).toBe('间距');
expect(gap?.helpText).not.toMatch(/Tailwind/i);
// The English source still says it — that copy is the spec's to fix, and
// this overlay deliberately does not rewrite the producer's own text.
expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(/Tailwind/i);
});

it('synthesizes ALL of the `header` composite’s sub-rows — a partial list would hide the rest', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const header = fieldByName(zh, 'header');
expect(header?.type).toBe('composite');
const children = (header?.fields ?? []).map((f) => f.field);
// Exactly `DashboardHeaderSchema`'s three properties. SchemaForm prefers a
// declared `fields` array over the schema-derived one, so anything absent
// here would disappear from the panel.
expect(children.sort()).toEqual(['actions', 'showDescription', 'showTitle']);
const byName = new Map((header!.fields ?? []).map((f) => [f.field, f]));
expect(byName.get('showTitle')?.label).toBe('显示标题');
expect(byName.get('showTitle')?.helpText).toBe('在页眉中显示仪表板名称');
expect(byName.get('showDescription')?.label).toBe('显示描述');
expect(byName.get('actions')?.label).toBe('操作按钮');
});
});

describe('getDashboardForm — the overlay reaches the panel, and caches per locale', () => {
it('serves the spec’s English by default and Chinese to a zh console', () => {
const en = getDashboardForm('en-US');
const zh = getDashboardForm('zh-CN');
expect(sections(en).map((s) => s.label)).toContain('Layout');
expect(sections(zh).map((s) => s.label)).toContain('布局');
});

it('does not serve one locale’s copy to another (the single-slot cache bug)', () => {
// Order matters: ask for zh first, then en. A single memo slot would hand
// the Chinese form to the English console.
expect(sections(getDashboardForm('zh-CN')).map((s) => s.label)).toContain('布局');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).toContain('Layout');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).not.toContain('布局');
});

it('is stable per locale', () => {
expect(getDashboardForm('zh-CN')).toBe(getDashboardForm('zh-CN'));
});

it('still prunes the fields the curated inspector owns', () => {
const zh = getDashboardForm('zh-CN');
for (const owned of ['name', 'label', 'description', 'widgets']) {
expect(fieldByName(zh, owned)).toBeUndefined();
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
27 changes: 27 additions & 0 deletions .changeset/studio-jargon-and-tool-card-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
'@object-ui/plugin-chatbot': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

Studio workbench and AI tool cards speak the author's language (objectui#7254)

- The Interfaces breadcrumb, canvas caption and navigation rail show the
metadata label plus a translated kind; the internal `type · name` pair moves
to the tooltip. An unlabelled nav leaf now falls back to its object name
instead of rendering an empty row.
- The Studio top-bar package switcher reads the package's human name from
either position the packages endpoint serves it in, instead of degrading a
registry-shaped entry to its reverse-domain id.
- The dashboard property panel is localized: the spec's authoring form is
overlaid through the platform's own `metadataForms.<type>` convention, so
section headings, field labels, hints and the `header` composite's sub-fields
render in Chinese (developer vocabulary such as "Tailwind units" is replaced
with something an author can act on, not transliterated).
- AI tool cards: tool titles resolve through `chatbot.tool.<name>` (all thirty
platform-provided tools, ten locale packs), the header status badge is
localized, and the plan count strip is a real plural family instead of an
English `+ "s"` concatenation.
- The tool card's header badge and its body badge now come from one producer:
a proposal that has been confirmed, built or published no longer keeps a
header reading "Awaiting Approval".
31 changes: 26 additions & 5 deletions packages/app-shell/src/views/metadata-admin/dashboard-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,8 @@
import { z } from 'zod';
import { DashboardSchema, dashboardForm as specDashboardForm } from '@objectstack/spec/ui';
import type { FormViewSpec } from './SchemaForm.js';
import type { SupportedLocale } from './i18n.js';
import { localizeMetadataForm } from './metadata-form-i18n.js';

type JsonSchema = Record<string, any>;

Expand DownExpand Up@@ -54,16 +56,28 @@ export function getDashboardSchema(): JsonSchema | undefined {
return _dashboardDocSchema;
}

let _dashboardForm: FormViewSpec | undefined;
/**
* Per-locale, because the form now carries LOCALIZED copy (objectui#7254) and
* a single slot would have served whichever locale asked first to everyone
* after it — a cache that is right until someone switches language.
*/
const _dashboardForm = new Map<string, FormViewSpec | undefined>();

/**
* The canonical authoring FormView, with the fields the curated inspector
* owns directly (widgets / label / description / name) pruned from every
* section so they are not double-rendered. Everything else — layout,
* filters, performance — flows through verbatim from the spec.
*
* The spec authors these strings in English. `localizeMetadataForm` overlays
* the active locale on top (objectui#7254) through the platform's own
* `metadataForms.<type>` convention, so the panel speaks the same language as
* the Studio around it. A locale the overlay does not carry gets the spec's
* English, exactly as before.
*/
export function getDashboardForm(): FormViewSpec | undefined {
if (_dashboardForm) return _dashboardForm;
export function getDashboardForm(locale?: SupportedLocale | string): FormViewSpec | undefined {
const cacheKey = String(locale ?? '');
if (_dashboardForm.has(cacheKey)) return _dashboardForm.get(cacheKey);
if (!specDashboardForm || typeof specDashboardForm !== 'object') return undefined;
try {
const clone = JSON.parse(JSON.stringify(specDashboardForm)) as FormViewSpec;
Expand All@@ -77,12 +91,19 @@ export function getDashboardForm(): FormViewSpec | undefined {
clone.sections = (clone.sections ?? []).filter(
(s: any) => (s.fields ?? []).length > 0,
);
_dashboardForm = clone;
// Localize AFTER pruning: the overlay only has to reach what renders, and
// pruning cannot drop a section the overlay just renamed.
const localized = localizeMetadataForm(
clone as unknown as Record<string, unknown>,
'dashboard',
locale,
) as unknown as FormViewSpec;
_dashboardForm.set(cacheKey, localized);
return localized;
} catch (err) {
if (typeof console !== 'undefined') {
console.warn('[dashboard-schema] failed to prepare dashboardForm from spec', err);
}
return undefined;
}
return _dashboardForm;
}
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1737,6 +1737,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.nav.noObjects': 'This package has no objects yet — create one in the Data pillar first.',
// Interfaces pillar
'engine.studio.if.pickLeft': 'Select a menu item on the left',
// objectui#7254 — the tooltip that keeps the DEVELOPER identity reachable
// after the breadcrumb/caption stopped printing it beside a localized
// label. Prefixed rather than bare: `dashboard · customer_dashboard` on
// its own does not say what it is, and it is also what the rail item's
// own tooltip carries — three elements sharing one tooltip string is a
// page nothing (a person or a test) can address unambiguously.
'engine.studio.if.internalId': 'Internal id',
'engine.studio.if.navHeading': '{app} · Navigation',
'engine.studio.if.editNavTitle': 'Edit navigation (drag to reorder / rename / add-remove)',
'engine.studio.if.doneEditTitle': 'Done editing',
Expand DownExpand Up@@ -3623,6 +3630,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.nav.noObjects': '这个软件包还没有对象 — 先到 Data 支柱创建。',
// Interfaces pillar
'engine.studio.if.pickLeft': '从左侧选择一个菜单项',
'engine.studio.if.internalId': '内部标识',
'engine.studio.if.navHeading': '{app} · 导航',
'engine.studio.if.editNavTitle': '编辑导航(拖拽排序 / 重命名 / 增删)',
'engine.studio.if.doneEditTitle': '完成编辑',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,7 +130,9 @@ export function DashboardDefaultInspector({
() =>
mergeServerFields({
bundledSchema: getDashboardSchema(),
bundledForm: getDashboardForm(),
// objectui#7254 — the spec form is English; the locale overlay lives
// inside `getDashboardForm` so every consumer of it gets the same copy.
bundledForm: getDashboardForm(locale),
serverSchema,
excludeFields: DASHBOARD_CURATED_FIELDS,
sectionTitle: t('engine.inspector.moreFields', locale),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7254 — the dashboard property panel was an all-English island
* inside a Chinese Studio.
*
* The panel is spec-driven on purpose (`dashboardForm` from
* `@objectstack/spec/ui` fed straight into SchemaForm), and the spec authors
* its copy in English, so every section heading, field label and hint reached
* the author untranslated — including "Grid gap (Tailwind units)", a unit only
* a developer can act on.
*
* These pins assert the OVERLAY, and specifically the two things a hand-rolled
* walker would have got wrong and a shape-only test would not have noticed:
* the section key is the SLUGGED label (not the label text), and a `composite`
* field's sub-rows are synthesized from the bundle — completely, or the ones
* left out vanish from the form.
*/
import { describe, expect, it } from 'vitest';
import { dashboardForm } from '@objectstack/spec/ui';
import { localizeMetadataForm, hasMetadataFormOverlay } from './metadata-form-i18n';
import { getDashboardForm } from './dashboard-schema';

/** The two form shapes these assertions read. `unknown` everywhere else. */
interface FormField {
field?: string;
type?: string;
label?: string;
helpText?: string;
fields?: FormField[];
}
interface Section {
label?: string;
description?: string;
fields?: FormField[];
}

function sections(form: unknown): Section[] {
return ((form as { sections?: Section[] } | undefined)?.sections ?? []) as Section[];
}

function fieldByName(form: unknown, name: string): FormField | undefined {
for (const s of sections(form)) {
for (const f of s.fields ?? []) {
if (f?.field === name) return f;
}
}
return undefined;
}

describe('localizeMetadataForm — the spec form, in the author’s language', () => {
it('has an overlay for `dashboard` at zh and none at en', () => {
expect(hasMetadataFormOverlay('dashboard', 'zh-CN')).toBe(true);
expect(hasMetadataFormOverlay('dashboard', 'en-US')).toBe(false);
});

it('leaves the form untouched (same object) outside zh', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'dashboard', 'en-US')).toBe(form);
});

it('leaves a type the overlay does not carry untouched', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'report', 'zh-CN')).toBe(form);
});

it('translates section headings — addressed by the SLUGGED label, not the label text', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const labels = sections(zh).map((s) => s.label);
expect(labels).toContain('布局'); // 'Layout' → section key `layout`
expect(labels).toContain('筛选'); // 'Filters' → `filters`
expect(labels).not.toContain('Layout');
const layout = sections(zh).find((s) => s.label === '布局');
expect(layout?.description).toBe('栅格尺寸与刷新频率。');
expect(layout?.description).not.toMatch(/refresh cadence/);
});

it('translates the layout field labels and hints the card named', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
expect(fieldByName(zh, 'columns')).toMatchObject({ label: '列数', helpText: '栅格列数(默认 12)' });
expect(fieldByName(zh, 'refreshInterval')?.label).toBe('自动刷新');
expect(fieldByName(zh, 'header')?.label).toBe('页眉');
});

it('drops the "Tailwind units" developer vocabulary rather than transliterating it', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const gap = fieldByName(zh, 'gap');
expect(gap?.label).toBe('间距');
expect(gap?.helpText).not.toMatch(/Tailwind/i);
// The English source still says it — that copy is the spec's to fix, and
// this overlay deliberately does not rewrite the producer's own text.
expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(/Tailwind/i);
});

it('synthesizes ALL of the `header` composite’s sub-rows — a partial list would hide the rest', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const header = fieldByName(zh, 'header');
expect(header?.type).toBe('composite');
const children = (header?.fields ?? []).map((f) => f.field);
// Exactly `DashboardHeaderSchema`'s three properties. SchemaForm prefers a
// declared `fields` array over the schema-derived one, so anything absent
// here would disappear from the panel.
expect(children.sort()).toEqual(['actions', 'showDescription', 'showTitle']);
const byName = new Map((header!.fields ?? []).map((f) => [f.field, f]));
expect(byName.get('showTitle')?.label).toBe('显示标题');
expect(byName.get('showTitle')?.helpText).toBe('在页眉中显示仪表板名称');
expect(byName.get('showDescription')?.label).toBe('显示描述');
expect(byName.get('actions')?.label).toBe('操作按钮');
});
});

describe('getDashboardForm — the overlay reaches the panel, and caches per locale', () => {
it('serves the spec’s English by default and Chinese to a zh console', () => {
const en = getDashboardForm('en-US');
const zh = getDashboardForm('zh-CN');
expect(sections(en).map((s) => s.label)).toContain('Layout');
expect(sections(zh).map((s) => s.label)).toContain('布局');
});

it('does not serve one locale’s copy to another (the single-slot cache bug)', () => {
// Order matters: ask for zh first, then en. A single memo slot would hand
// the Chinese form to the English console.
expect(sections(getDashboardForm('zh-CN')).map((s) => s.label)).toContain('布局');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).toContain('Layout');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).not.toContain('布局');
});

it('is stable per locale', () => {
expect(getDashboardForm('zh-CN')).toBe(getDashboardForm('zh-CN'));
});

it('still prunes the fields the curated inspector owns', () => {
const zh = getDashboardForm('zh-CN');
for (const owned of ['name', 'label', 'description', 'widgets']) {
expect(fieldByName(zh, owned)).toBeUndefined();
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
27 changes: 27 additions & 0 deletions .changeset/studio-jargon-and-tool-card-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
'@object-ui/plugin-chatbot': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

Studio workbench and AI tool cards speak the author's language (objectui#7254)

- The Interfaces breadcrumb, canvas caption and navigation rail show the
metadata label plus a translated kind; the internal `type · name` pair moves
to the tooltip. An unlabelled nav leaf now falls back to its object name
instead of rendering an empty row.
- The Studio top-bar package switcher reads the package's human name from
either position the packages endpoint serves it in, instead of degrading a
registry-shaped entry to its reverse-domain id.
- The dashboard property panel is localized: the spec's authoring form is
overlaid through the platform's own `metadataForms.<type>` convention, so
section headings, field labels, hints and the `header` composite's sub-fields
render in Chinese (developer vocabulary such as "Tailwind units" is replaced
with something an author can act on, not transliterated).
- AI tool cards: tool titles resolve through `chatbot.tool.<name>` (all thirty
platform-provided tools, ten locale packs), the header status badge is
localized, and the plan count strip is a real plural family instead of an
English `+ "s"` concatenation.
- The tool card's header badge and its body badge now come from one producer:
a proposal that has been confirmed, built or published no longer keeps a
header reading "Awaiting Approval".
31 changes: 26 additions & 5 deletions packages/app-shell/src/views/metadata-admin/dashboard-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,8 @@
import { z } from 'zod';
import { DashboardSchema, dashboardForm as specDashboardForm } from '@objectstack/spec/ui';
import type { FormViewSpec } from './SchemaForm.js';
import type { SupportedLocale } from './i18n.js';
import { localizeMetadataForm } from './metadata-form-i18n.js';

type JsonSchema = Record<string, any>;

Expand DownExpand Up@@ -54,16 +56,28 @@ export function getDashboardSchema(): JsonSchema | undefined {
return _dashboardDocSchema;
}

let _dashboardForm: FormViewSpec | undefined;
/**
* Per-locale, because the form now carries LOCALIZED copy (objectui#7254) and
* a single slot would have served whichever locale asked first to everyone
* after it — a cache that is right until someone switches language.
*/
const _dashboardForm = new Map<string, FormViewSpec | undefined>();

/**
* The canonical authoring FormView, with the fields the curated inspector
* owns directly (widgets / label / description / name) pruned from every
* section so they are not double-rendered. Everything else — layout,
* filters, performance — flows through verbatim from the spec.
*
* The spec authors these strings in English. `localizeMetadataForm` overlays
* the active locale on top (objectui#7254) through the platform's own
* `metadataForms.<type>` convention, so the panel speaks the same language as
* the Studio around it. A locale the overlay does not carry gets the spec's
* English, exactly as before.
*/
export function getDashboardForm(): FormViewSpec | undefined {
if (_dashboardForm) return _dashboardForm;
export function getDashboardForm(locale?: SupportedLocale | string): FormViewSpec | undefined {
const cacheKey = String(locale ?? '');
if (_dashboardForm.has(cacheKey)) return _dashboardForm.get(cacheKey);
if (!specDashboardForm || typeof specDashboardForm !== 'object') return undefined;
try {
const clone = JSON.parse(JSON.stringify(specDashboardForm)) as FormViewSpec;
Expand All@@ -77,12 +91,19 @@ export function getDashboardForm(): FormViewSpec | undefined {
clone.sections = (clone.sections ?? []).filter(
(s: any) => (s.fields ?? []).length > 0,
);
_dashboardForm = clone;
// Localize AFTER pruning: the overlay only has to reach what renders, and
// pruning cannot drop a section the overlay just renamed.
const localized = localizeMetadataForm(
clone as unknown as Record<string, unknown>,
'dashboard',
locale,
) as unknown as FormViewSpec;
_dashboardForm.set(cacheKey, localized);
return localized;
} catch (err) {
if (typeof console !== 'undefined') {
console.warn('[dashboard-schema] failed to prepare dashboardForm from spec', err);
}
return undefined;
}
return _dashboardForm;
}
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1737,6 +1737,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.nav.noObjects': 'This package has no objects yet — create one in the Data pillar first.',
// Interfaces pillar
'engine.studio.if.pickLeft': 'Select a menu item on the left',
// objectui#7254 — the tooltip that keeps the DEVELOPER identity reachable
// after the breadcrumb/caption stopped printing it beside a localized
// label. Prefixed rather than bare: `dashboard · customer_dashboard` on
// its own does not say what it is, and it is also what the rail item's
// own tooltip carries — three elements sharing one tooltip string is a
// page nothing (a person or a test) can address unambiguously.
'engine.studio.if.internalId': 'Internal id',
'engine.studio.if.navHeading': '{app} · Navigation',
'engine.studio.if.editNavTitle': 'Edit navigation (drag to reorder / rename / add-remove)',
'engine.studio.if.doneEditTitle': 'Done editing',
Expand DownExpand Up@@ -3623,6 +3630,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.nav.noObjects': '这个软件包还没有对象 — 先到 Data 支柱创建。',
// Interfaces pillar
'engine.studio.if.pickLeft': '从左侧选择一个菜单项',
'engine.studio.if.internalId': '内部标识',
'engine.studio.if.navHeading': '{app} · 导航',
'engine.studio.if.editNavTitle': '编辑导航(拖拽排序 / 重命名 / 增删)',
'engine.studio.if.doneEditTitle': '完成编辑',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,7 +130,9 @@ export function DashboardDefaultInspector({
() =>
mergeServerFields({
bundledSchema: getDashboardSchema(),
bundledForm: getDashboardForm(),
// objectui#7254 — the spec form is English; the locale overlay lives
// inside `getDashboardForm` so every consumer of it gets the same copy.
bundledForm: getDashboardForm(locale),
serverSchema,
excludeFields: DASHBOARD_CURATED_FIELDS,
sectionTitle: t('engine.inspector.moreFields', locale),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7254 — the dashboard property panel was an all-English island
* inside a Chinese Studio.
*
* The panel is spec-driven on purpose (`dashboardForm` from
* `@objectstack/spec/ui` fed straight into SchemaForm), and the spec authors
* its copy in English, so every section heading, field label and hint reached
* the author untranslated — including "Grid gap (Tailwind units)", a unit only
* a developer can act on.
*
* These pins assert the OVERLAY, and specifically the two things a hand-rolled
* walker would have got wrong and a shape-only test would not have noticed:
* the section key is the SLUGGED label (not the label text), and a `composite`
* field's sub-rows are synthesized from the bundle — completely, or the ones
* left out vanish from the form.
*/
import { describe, expect, it } from 'vitest';
import { dashboardForm } from '@objectstack/spec/ui';
import { localizeMetadataForm, hasMetadataFormOverlay } from './metadata-form-i18n';
import { getDashboardForm } from './dashboard-schema';

/** The two form shapes these assertions read. `unknown` everywhere else. */
interface FormField {
field?: string;
type?: string;
label?: string;
helpText?: string;
fields?: FormField[];
}
interface Section {
label?: string;
description?: string;
fields?: FormField[];
}

function sections(form: unknown): Section[] {
return ((form as { sections?: Section[] } | undefined)?.sections ?? []) as Section[];
}

function fieldByName(form: unknown, name: string): FormField | undefined {
for (const s of sections(form)) {
for (const f of s.fields ?? []) {
if (f?.field === name) return f;
}
}
return undefined;
}

describe('localizeMetadataForm — the spec form, in the author’s language', () => {
it('has an overlay for `dashboard` at zh and none at en', () => {
expect(hasMetadataFormOverlay('dashboard', 'zh-CN')).toBe(true);
expect(hasMetadataFormOverlay('dashboard', 'en-US')).toBe(false);
});

it('leaves the form untouched (same object) outside zh', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'dashboard', 'en-US')).toBe(form);
});

it('leaves a type the overlay does not carry untouched', () => {
const form = dashboardForm as unknown as Record<string, unknown>;
expect(localizeMetadataForm(form, 'report', 'zh-CN')).toBe(form);
});

it('translates section headings — addressed by the SLUGGED label, not the label text', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const labels = sections(zh).map((s) => s.label);
expect(labels).toContain('布局'); // 'Layout' → section key `layout`
expect(labels).toContain('筛选'); // 'Filters' → `filters`
expect(labels).not.toContain('Layout');
const layout = sections(zh).find((s) => s.label === '布局');
expect(layout?.description).toBe('栅格尺寸与刷新频率。');
expect(layout?.description).not.toMatch(/refresh cadence/);
});

it('translates the layout field labels and hints the card named', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
expect(fieldByName(zh, 'columns')).toMatchObject({ label: '列数', helpText: '栅格列数(默认 12)' });
expect(fieldByName(zh, 'refreshInterval')?.label).toBe('自动刷新');
expect(fieldByName(zh, 'header')?.label).toBe('页眉');
});

it('drops the "Tailwind units" developer vocabulary rather than transliterating it', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const gap = fieldByName(zh, 'gap');
expect(gap?.label).toBe('间距');
expect(gap?.helpText).not.toMatch(/Tailwind/i);
// The English source still says it — that copy is the spec's to fix, and
// this overlay deliberately does not rewrite the producer's own text.
expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(/Tailwind/i);
});

it('synthesizes ALL of the `header` composite’s sub-rows — a partial list would hide the rest', () => {
const zh = localizeMetadataForm(
dashboardForm as unknown as Record<string, unknown>,
'dashboard',
'zh-CN',
);
const header = fieldByName(zh, 'header');
expect(header?.type).toBe('composite');
const children = (header?.fields ?? []).map((f) => f.field);
// Exactly `DashboardHeaderSchema`'s three properties. SchemaForm prefers a
// declared `fields` array over the schema-derived one, so anything absent
// here would disappear from the panel.
expect(children.sort()).toEqual(['actions', 'showDescription', 'showTitle']);
const byName = new Map((header!.fields ?? []).map((f) => [f.field, f]));
expect(byName.get('showTitle')?.label).toBe('显示标题');
expect(byName.get('showTitle')?.helpText).toBe('在页眉中显示仪表板名称');
expect(byName.get('showDescription')?.label).toBe('显示描述');
expect(byName.get('actions')?.label).toBe('操作按钮');
});
});

describe('getDashboardForm — the overlay reaches the panel, and caches per locale', () => {
it('serves the spec’s English by default and Chinese to a zh console', () => {
const en = getDashboardForm('en-US');
const zh = getDashboardForm('zh-CN');
expect(sections(en).map((s) => s.label)).toContain('Layout');
expect(sections(zh).map((s) => s.label)).toContain('布局');
});

it('does not serve one locale’s copy to another (the single-slot cache bug)', () => {
// Order matters: ask for zh first, then en. A single memo slot would hand
// the Chinese form to the English console.
expect(sections(getDashboardForm('zh-CN')).map((s) => s.label)).toContain('布局');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).toContain('Layout');
expect(sections(getDashboardForm('en-US')).map((s) => s.label)).not.toContain('布局');
});

it('is stable per locale', () => {
expect(getDashboardForm('zh-CN')).toBe(getDashboardForm('zh-CN'));
});

it('still prunes the fields the curated inspector owns', () => {
const zh = getDashboardForm('zh-CN');
for (const owned of ['name', 'label', 'description', 'widgets']) {
expect(fieldByName(zh, owned)).toBeUndefined();
}
});
});
Loading
Loading