From a77f4ee9d4f54ca2b96accb44c048baff378a930 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:35:41 +0000 Subject: [PATCH 1/5] feat(spec): a page object-form's thank-you panel is translated through components.ID.submitBehavior translatePage overlays an object-form component's properties.submitBehavior title / message from pages.PAGE.components.ID.submitBehavior, through translateFormSubmitBehavior, now generic over its lookup so the form view and the page component share one comparison. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyMTiYRPdRNbNBCGdF --- .../i18n-resolver.page-form-thank-you.test.ts | 219 ++++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 97 ++++++-- packages/spec/src/system/translation.zod.ts | 76 ++++-- 3 files changed, 358 insertions(+), 34 deletions(-) create mode 100644 packages/spec/src/system/i18n-resolver.page-form-thank-you.test.ts diff --git a/packages/spec/src/system/i18n-resolver.page-form-thank-you.test.ts b/packages/spec/src/system/i18n-resolver.page-form-thank-you.test.ts new file mode 100644 index 00000000000..c14726855cd --- /dev/null +++ b/packages/spec/src/system/i18n-resolver.page-form-thank-you.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin: `translatePage` localizes a page `object-form` component's thank-you + * panel — the `title` and `message` of a `kind: 'thank-you'` + * `properties.submitBehavior` (`ObjectFormPropsSchema.submitBehavior`, the + * form view's own member by reference) — through + * `pages..components..submitBehavior.{title,message}`, addressed by + * the form component's own id and applied by the same function, and so the + * same rules, as a form view's panel. + * + * Before this, the panel had no translation key at all: the strict + * `components.` entry declared no `submitBehavior`, and `translatePage` + * never read one. A page that embeds a thank-you form confirmed the submit in + * the authoring language on a translated page. + * + * The page and the bundle node are PARSED by the authoring schemas, so an + * address neither schema carries cannot pass. + */ + +import { describe, it, expect } from 'vitest'; +import { translatePage, translateMetadataDocument } from './i18n-resolver'; +import { TranslationDataSchema, type TranslationBundle } from './translation.zod'; +import { PageSchema } from '../ui/page.zod'; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); + +const PAGE = { + name: 'project_wizard', + label: 'New Project', + type: 'app' as const, + regions: [ + { + name: 'main', + components: [ + { + id: 'project_form', + type: 'object-form', + properties: { + objectName: 'project', + mode: 'create', + title: 'Create a Project', + submitBehavior: { kind: 'thank-you', title: 'Project created', message: 'Your project is ready.' }, + }, + }, + // Authors a title and no message: the bundle's message must not invent one. + { + id: 'title_only_form', + type: 'object-form', + properties: { objectName: 'project', submitBehavior: { kind: 'thank-you', title: 'Got it' } }, + }, + // Not a thank-you panel: its keys translate nothing. + { + id: 'redirect_form', + type: 'object-form', + properties: { objectName: 'project', submitBehavior: { kind: 'redirect', url: '/projects' } }, + }, + // Authors a panel the bundle carries no key for. + { + id: 'referral_form', + type: 'object-form', + properties: { objectName: 'project', submitBehavior: { kind: 'thank-you', title: 'Referral sent' } }, + }, + // Not an `object-form`: a `submitBehavior` key under its id is not a panel's. + { + id: 'summary_card', + type: 'page:card', + properties: { title: 'Summary' }, + }, + // Nested in a container: reached by the id route. + { + id: 'side_card', + type: 'page:card', + properties: { + title: 'Quick add', + children: [ + { + id: 'quick_form', + type: 'object-form', + properties: { objectName: 'project', submitBehavior: { kind: 'thank-you', title: 'Added' } }, + }, + ], + }, + }, + ], + }, + ], +}; + +const PAGE_ZH = { + components: { + project_form: { + title: '创建项目', + submitBehavior: { title: '项目已创建', message: '您的项目已就绪。' }, + }, + title_only_form: { submitBehavior: { title: '收到', message: 'MUST-NOT-APPLY' } }, + redirect_form: { submitBehavior: { title: 'MUST-NOT-APPLY', message: 'MUST-NOT-APPLY' } }, + summary_card: { submitBehavior: { title: 'MUST-NOT-APPLY' } }, + quick_form: { submitBehavior: { title: '已添加' } }, + }, +}; +const PAGE_EN = { components: { project_form: { submitBehavior: { title: 'Project created' } } } }; + +const BUNDLE: TranslationBundle = { + en: { pages: { project_wizard: PAGE_EN } } as any, + 'zh-CN': { pages: { project_wizard: PAGE_ZH } } as any, +}; + +const components = (page: any): any[] => page.regions[0].components; +const byId = (page: any, id: string): any => { + const found = components(page).find((c) => c.id === id) + ?? components(page).flatMap((c) => c.properties?.children ?? []).find((c: any) => c.id === id); + if (!found) throw new Error(`fixture missing ${id}`); + return found; +}; + +describe("translatePage — an `object-form`'s thank-you panel, keyed `components..submitBehavior`", () => { + it('composes the identity this test is pinned to — legal fixtures, the panel lives under `properties`', () => { + const page = PageSchema.safeParse(PAGE); + expect(page.success, JSON.stringify(page.error?.issues)).toBe(true); + for (const node of [PAGE_ZH, PAGE_EN]) { + const parsed = TranslationDataSchema.safeParse({ pages: { project_wizard: node } }); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + } + }); + + it('a zh-CN pack yields the translated panel title and message', () => { + const out = translateMetadataDocument('page', clone(PAGE), BUNDLE, { locale: 'zh-CN' }); + expect(byId(out, 'project_form').properties.submitBehavior) + .toEqual({ kind: 'thank-you', title: '项目已创建', message: '您的项目已就绪。' }); + // The form's own heading is translated beside it, from its own key. + expect(byId(out, 'project_form').properties.title).toBe('创建项目'); + // A form nested in a container is reached by the same id route. + expect(byId(out, 'quick_form').properties.submitBehavior).toEqual({ kind: 'thank-you', title: '已添加' }); + }); + + it('a locale the bundle lacks reads the declared fallback chain — ja-JP over `[zh-CN]`', () => { + const out = translatePage(clone(PAGE), BUNDLE, { locale: 'ja-JP', fallbackChain: ['zh-CN'] }); + expect(byId(out, 'project_form').properties.submitBehavior.title).toBe('项目已创建'); + expect(byId(out, 'project_form').properties.submitBehavior.message).toBe('您的项目已就绪。'); + }); + + it('a missing key yields the authored panel', () => { + const out = translatePage(clone(PAGE), BUNDLE, { locale: 'zh-CN' }); + expect(byId(out, 'referral_form').properties.submitBehavior).toEqual({ kind: 'thank-you', title: 'Referral sent' }); + }); + + it('CONTROL: a panel that authors no message gains none — the bundle key does not invent one', () => { + const out = translatePage(clone(PAGE), BUNDLE, { locale: 'zh-CN' }); + const behavior = byId(out, 'title_only_form').properties.submitBehavior; + expect(behavior).toEqual({ kind: 'thank-you', title: '收到' }); + expect('message' in behavior).toBe(false); + }); + + it('CONTROL: a `redirect` behavior is not a panel — its keys translate nothing and the component is kept', () => { + const doc = clone(PAGE); + const out = translatePage(doc, BUNDLE, { locale: 'zh-CN' }); + expect(byId(out, 'redirect_form')).toEqual(byId(doc, 'redirect_form')); + expect(byId(out, 'redirect_form').properties.submitBehavior).toEqual({ kind: 'redirect', url: '/projects' }); + }); + + it("CONTROL: a key under another component's id does not reach the form, and a non-form component gains no panel", () => { + const elsewhere: TranslationBundle = { + 'zh-CN': { pages: { project_wizard: { components: { referral_form: { submitBehavior: { title: '项目已创建' } } } } } } as any, + }; + const out = translatePage(clone(PAGE), elsewhere, { locale: 'zh-CN' }); + expect(byId(out, 'project_form').properties.submitBehavior.title).toBe('Project created'); + const card = byId(translatePage(clone(PAGE), BUNDLE, { locale: 'zh-CN' }), 'summary_card'); + expect('submitBehavior' in card.properties).toBe(false); + }); + + it("CONTROL: the panel title key does not reach the component's title, and the title key does not reach the panel", () => { + const titleOnly: TranslationBundle = { + 'zh-CN': { pages: { project_wizard: { components: { project_form: { title: '创建项目' } } } } } as any, + }; + const viaTitle = byId(translatePage(clone(PAGE), titleOnly, { locale: 'zh-CN' }), 'project_form'); + expect(viaTitle.properties.title).toBe('创建项目'); + expect(viaTitle.properties.submitBehavior.title).toBe('Project created'); + const panelOnly: TranslationBundle = { + 'zh-CN': { pages: { project_wizard: { components: { project_form: { submitBehavior: { title: '项目已创建' } } } } } } as any, + }; + const viaPanel = byId(translatePage(clone(PAGE), panelOnly, { locale: 'zh-CN' }), 'project_form'); + expect(viaPanel.properties.title).toBe('Create a Project'); + expect(viaPanel.properties.submitBehavior.title).toBe('项目已创建'); + }); + + it('CONTROL: an `object-form` without an id has no key', () => { + const anonymous = clone(PAGE); + delete (components(anonymous)[0] as any).id; + const out = translatePage(anonymous, BUNDLE, { locale: 'zh-CN' }); + expect(components(out)[0].properties.submitBehavior.title).toBe('Project created'); + }); + + it('the page document is not mutated', () => { + const doc = clone(PAGE); + translatePage(doc, BUNDLE, { locale: 'zh-CN' }); + expect(byId(doc, 'project_form').properties.submitBehavior).toEqual(PAGE.regions[0].components[0].properties.submitBehavior); + }); +}); + +describe('the `pages..components..submitBehavior` translation face', () => { + const issuesOf = (node: unknown) => { + const parsed = TranslationDataSchema.safeParse({ pages: { project_wizard: { components: { project_form: node } } } }); + return parsed.success ? [] : parsed.error.issues; + }; + + it('carries the thank-you `title` and `message` and nothing else', () => { + expect(issuesOf({ submitBehavior: { title: '项目已创建', message: '您的项目已就绪。' } })).toEqual([]); + const [issue] = issuesOf({ submitBehavior: { title: '项目已创建', url: '/zh/projects' } }); + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.path).toEqual(['pages', 'project_wizard', 'components', 'project_form', 'submitBehavior']); + }); + + it('refuses a copied `kind` with a prescription naming the two translatable strings', () => { + const [issue] = issuesOf({ submitBehavior: { kind: 'thank-you', title: '项目已创建' } }); + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain('`title` and `message`'); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 28956d52345..dfefaad693b 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1286,11 +1286,19 @@ function translateFormTitle( } /** - * A served form view's translated thank-you panel (`FormViewSchema - * .submitBehavior`, at `config.submitBehavior`), read from - * `objects.._views..submitBehavior.{title,message}` — the - * same `_views` node {@link translateFormTitle} reads the form's heading - * from, one level down, the way the authored copy sits one level down. + * A form's translated thank-you panel (`FormViewSchema.submitBehavior`) — the + * ONE comparison both of its carriers run, each supplying its own address as + * `lookup`: + * + * - a served form view, at `config.submitBehavior`, read from + * `objects.._views..submitBehavior.{title,message}` — the + * same `_views` node {@link translateFormTitle} reads the form's heading + * from, one level down, the way the authored copy sits one level down + * ({@link translateView}); + * - a page `object-form` component, at `properties.submitBehavior` (the form + * view's member by reference), read from + * `pages..components..submitBehavior.{title,message}` + * ({@link translatePage}). * * Returns `behavior` itself (the same reference) when nothing applies, so the * caller can tell "unchanged" by identity: @@ -1308,20 +1316,17 @@ function translateFormTitle( */ function translateFormSubmitBehavior( behavior: unknown, - bundle: TranslationBundle, - objectName: string, - viewKey: string, - opts: ResolveOptions | undefined, + lookup: (attr: SubmitBehaviorCopyKey) => string | undefined, packagedConfig: Record | undefined, ): unknown { const served = asRecord(behavior); if (!served || served.kind !== 'thank-you') return behavior; const packaged = packagedConfig === undefined ? undefined : asRecord(packagedConfig.submitBehavior) ?? {}; - const text = (attr: 'title' | 'message'): string | undefined => { + const text = (attr: SubmitBehaviorCopyKey): string | undefined => { const value = served[attr]; if (typeof value !== 'string' || value.length === 0) return undefined; if (valueOverridesPackagedBase(packaged, attr, value)) return undefined; - return lookupSubmitBehaviorText(bundle, objectName, viewKey, attr, opts); + return lookup(attr); }; const title = text('title'); const message = text('message'); @@ -1333,6 +1338,9 @@ function translateFormSubmitBehavior( }; } +/** The two strings a thank-you panel authors, and so the two its translation carries. */ +type SubmitBehaviorCopyKey = 'title' | 'message'; + /** * One string off a view's `_views..submitBehavior` node, across the * locale chain — `undefined` when no locale carries it. @@ -1341,7 +1349,7 @@ function lookupSubmitBehaviorText( bundle: TranslationBundle, objectName: string, viewKey: string, - attr: 'title' | 'message', + attr: SubmitBehaviorCopyKey, opts: ResolveOptions | undefined, ): string | undefined { for (const code of localeChain(opts)) { @@ -1494,12 +1502,10 @@ export function translateView( opts, packagedConfig, ); + const viewKey = viewTranslationKey(view, objectName); const submitBehavior = translateFormSubmitBehavior( config.submitBehavior, - bundle, - objectName, - viewTranslationKey(view, objectName), - opts, + (attr) => lookupSubmitBehaviorText(bundle, objectName, viewKey, attr, opts), packagedConfig, ); if ( @@ -2802,6 +2808,37 @@ function lookupPageComponentCopy( */ export const PAGE_TABS_COMPONENT_TYPE = 'page:tabs'; +/** + * The one component type whose thank-you panel + * `pages..components..submitBehavior.{title,message}` addresses: + * `object-form`, whose `properties.submitBehavior` is the form view's own + * member by reference (`ObjectFormPropsSchema.submitBehavior`). + * + * Exported for {@link PAGE_TABS_COMPONENT_TYPE}'s reason: `translatePage`, + * the CLI extractor and `objectstack validate` must agree on which component + * carries the key. + */ +export const PAGE_FORM_COMPONENT_TYPE = 'object-form'; + +/** + * One string off a page component's `components..submitBehavior` node, + * across the locale chain — `undefined` when no locale carries it. Resolved + * key by key, as {@link lookupPageComponentCopy} is. + */ +function lookupPageFormSubmitBehaviorText( + bundle: TranslationBundle, + name: string, + id: string, + attr: SubmitBehaviorCopyKey, + opts: ResolveOptions | undefined, +): string | undefined { + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.pages?.[name]?.components?.[id]?.submitBehavior?.[attr]; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + /** * The key a `page:tabs` item is addressed by under * `pages..components..items` — its authored `value` @@ -3299,6 +3336,16 @@ export function walkAddressedPageComponents( * key (an index token is never one), and an item the bundle does not key — * an inline locale map included — is left as authored. * + * An `object-form` component's thank-you panel is translated too: its + * `properties.submitBehavior` `title` / `message` overlay from + * `pages..components..submitBehavior`, addressed by the form + * component's own id beside its `title` / `description`. The panel is the + * page's copy, so it is keyed under the page rather than under the bound + * object, and {@link translateFormSubmitBehavior} applies it by the form + * view's panel's rules — one comparison for both carriers. Before this key a + * page that embeds a thank-you form confirmed the submit in the authoring + * language on a translated page. + * * A list page's filter-preset tab bar * (`interfaceConfig.userFilters.tabs[].label`) is translated too, against * `objects.._tabs..label` — see @@ -3387,6 +3434,24 @@ export function translatePage( next = translatePageTabItems(next, bundle, name, id as string, opts); } + // An `object-form`'s thank-you panel, under the form component's own id + // (`pages..components..submitBehavior`), on the same + // `addressed` arbitration. The rules are the form view's panel's, through + // the one function both carriers share; `translatePage` is handed no + // packaged base for any page string, so the catalog applies as it does + // to this component's `title` and `description`. + if (addressed && component.type === PAGE_FORM_COMPONENT_TYPE) { + const behavior = next.properties?.submitBehavior; + const submitBehavior = translateFormSubmitBehavior( + behavior, + (attr) => lookupPageFormSubmitBehaviorText(bundle, name, id as string, attr, opts), + undefined, + ); + if (submitBehavior !== behavior) { + next = { ...next, properties: { ...next.properties, submitBehavior } }; + } + } + // The page-name header route addresses THE page's header, so it stops at // root level — nested components are reached by the id route only. if (nested) return next; diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 0225a607d55..a222e92bc35 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -261,6 +261,36 @@ const actionTranslationSchema = (surface: string) => strictObject({ * } * ``` */ +/** + * A form's thank-you panel translation — one shape, used at two addresses: a + * form view's `_views..submitBehavior` and a page `object-form` + * component's `pages..components..submitBehavior` (the block + * `ObjectFormPropsSchema.submitBehavior` takes from the form view by + * reference). Built by a factory for {@link actionTranslationSchema}'s + * reason: the two cannot drift, and `surface` says which of the two a + * rejected key was written on. + * + * It carries the thank-you member's two strings only: the three other kinds + * (`redirect`, `continue`, `next-record`) author no copy. + */ +const submitBehaviorTranslationSchema = (surface: string) => strictObject({ + surface, + history: TRANSLATION_HISTORY, + // Mirrors the thank-you member's own alias table in `ui/view.zod.ts`. + aliases: { heading: 'title', text: 'message', body: 'message', description: 'message' }, + guidance: { + url: + "`url` is not display copy — a `redirect` submit behavior's path is the same in every " + + 'locale. Only a `thank-you` panel authors copy: translate its `title` and `message`.', + kind: + '`kind` is not display copy — it selects the behavior and is the same in every locale. ' + + 'Translate the thank-you panel\'s `title` and `message` only.', + }, +}, { + title: z.string().optional().describe('Translated thank-you panel heading (overlays `submitBehavior.title`)'), + message: z.string().optional().describe('Translated thank-you panel message (overlays `submitBehavior.message`)'), +}); + export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ surface: 'this object translation', history: TRANSLATION_HISTORY, @@ -364,23 +394,7 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ * `title` / `message`: a form with another kind, or none, gains no * panel copy, and the console's own default wording stays in charge. */ - submitBehavior: strictObject({ - surface: 'this form submit behavior translation', - history: TRANSLATION_HISTORY, - // Mirrors the thank-you member's own alias table in `ui/view.zod.ts`. - aliases: { heading: 'title', text: 'message', body: 'message', description: 'message' }, - guidance: { - url: - "`url` is not display copy — a `redirect` submit behavior's path is the same in every " - + 'locale. Only a `thank-you` panel authors copy: translate its `title` and `message`.', - kind: - '`kind` is not display copy — it selects the behavior and is the same in every locale. ' - + 'Translate the thank-you panel\'s `title` and `message` only.', - }, - }, { - title: z.string().optional().describe('Translated thank-you panel heading (overlays `submitBehavior.title`)'), - message: z.string().optional().describe('Translated thank-you panel message (overlays `submitBehavior.message`)'), - }).optional().describe("Translated thank-you panel copy — overlays a form view's `submitBehavior` `title` / `message` when its `kind` is `'thank-you'`; only copy the form authors is translated"), + submitBehavior: submitBehaviorTranslationSchema('this form submit behavior translation').optional().describe("Translated thank-you panel copy — overlays a form view's `submitBehavior` `title` / `message` when its `kind` is `'thank-you'`; only copy the form authors is translated"), emptyState: strictObject({ surface: 'this view empty-state translation', history: TRANSLATION_HISTORY, @@ -1276,6 +1290,9 @@ const appTranslationDataShape = () => ({ * pages..components..items..label * → that `page:tabs` item's `label`, keyed by the * item's `value` (see `items` below) + * pages..components..submitBehavior.title / .message + * → that `object-form`'s thank-you panel (see + * `submitBehavior` below) * * When `title` is omitted, the header keeps the title its author wrote. * `label` stands in for it only where the header authors no title, or where @@ -1397,7 +1414,7 @@ const appTranslationDataShape = () => ({ // A page component's headline is `title`; the PAGE's is `label`. Same // document one level apart with opposite spellings — the same trap // `dashboards.widgets` names, so it gets the same alias table. - aliases: { name: 'title', heading: 'title', text: 'label', caption: 'description', help: 'description', helpText: 'description', empty: 'emptyText', emptyState: 'emptyText' }, + aliases: { name: 'title', heading: 'title', text: 'label', caption: 'description', help: 'description', helpText: 'description', empty: 'emptyText', emptyState: 'emptyText', thankYou: 'submitBehavior' }, guidance: { submitLabel: '`submitLabel` was removed in @objectstack/spec 17 (ADR-0049) — its only ' @@ -1452,6 +1469,29 @@ const appTranslationDataShape = () => ({ }, { label: z.string().optional().describe("Translated tab label — overlays the item's own `label` (`PageTabsProps.items[].label`)"), })).optional().describe('`page:tabs` item labels keyed by the item `value` (`PageTabsProps.items[].value`); an item without a `value` has no key'), + /** + * An `object-form` component's thank-you panel — overlays the `title` + * and `message` of `properties.submitBehavior` when its `kind` is + * `'thank-you'` (`ObjectFormPropsSchema.submitBehavior`, the form + * view's own member by reference), read by `translatePage`, offered by + * `os i18n extract`, judged by `objectstack validate`. + * + * **Under the page, not under the bound object.** The panel is copy the + * PAGE authors: two pages binding the same object can confirm a submit + * in different words, and a key under `objects.` (the + * `_sections` precedent) would make them share one translation. The + * component's own `id` already addresses its `title` / `description` + * here, so its panel sits beside them; a form view's panel keeps its + * own address under `_views`, because there the copy is the view's. + * + * Nested the way the authored copy is nested, so the panel's heading + * never shares the component's own `title` key. The same rules as the + * form view's panel apply, through the one function both share: only a + * `kind: 'thank-you'` panel is touched, only authored copy is + * translated, and a missing key keeps the authored string. An + * `object-form` without an `id` has no key; give it one. + */ + submitBehavior: submitBehaviorTranslationSchema('this page form submit behavior translation').optional().describe("Translated thank-you panel copy — overlays an `object-form` component's `submitBehavior` `title` / `message` when its `kind` is `'thank-you'`; only copy the form authors is translated"), })).optional().describe('Per-component copy keyed by component id (`PageComponentSchema.id`)'), })).optional().describe('Page translations keyed by page name'), From 5e7008a29dea6cb9b0afd35eda56eb5afd921691 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:37:11 +0000 Subject: [PATCH 2/5] feat(cli,lint): scaffold and judge a page object-form's thank-you panel key Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyMTiYRPdRNbNBCGdF --- packages/cli/src/utils/i18n-extract.ts | 34 +++++- .../i18n-page-form-thank-you-coverage.test.ts | 112 ++++++++++++++++++ .../validate-translation-references.test.ts | 74 ++++++++++++ .../src/validate-translation-references.ts | 45 +++++-- 4 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 packages/cli/test/i18n-page-form-thank-you-coverage.test.ts diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 0f32c07848e..666d74b932a 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -113,6 +113,8 @@ * pages..title / .subtitle (from the page's `page:header` component) * pages..components.. (per-component copy, #6080) * pages..components..items..label (a `page:tabs` item label) + * pages..components..submitBehavior.title / .message + * (an `object-form`'s thank-you panel) * flows..label * flows..screens..title (#7646 / #11287) * flows..screens..description (#22507) @@ -154,6 +156,7 @@ import { FLOW_SCREEN_FIELD_COPY_KEYS, FLOW_TERMINAL_MESSAGE_KEYS, PAGE_TABS_COMPONENT_TYPE, + PAGE_FORM_COMPONENT_TYPE, globalFilterKey, pageTabItemKey, walkAddressedPageComponents, @@ -489,9 +492,29 @@ function pushFormViewCopy(out: ExpectedEntry[], objectName: string, key: string, const form = config && typeof config === 'object' ? config as { title?: unknown; submitBehavior?: any } : undefined; const root = ['objects', objectName, '_views', key]; pushOptional(out, [...root, 'title'], form?.title, 'view', { objectName }); - const thankYou = form?.submitBehavior?.kind === 'thank-you' ? form.submitBehavior : undefined; - pushOptional(out, [...root, 'submitBehavior', 'title'], thankYou?.title, 'view', { objectName }); - pushOptional(out, [...root, 'submitBehavior', 'message'], thankYou?.message, 'view', { objectName }); + pushThankYouPanel(out, root, form?.submitBehavior, 'view', { objectName }); +} + +/** + * Emit `.submitBehavior.title` / `.message` for one form's + * `submitBehavior` — the panel's two slots, under whichever address its + * carrier has: a form view's `_views.` ({@link pushFormViewCopy}) or a + * page `object-form` component's `pages..components.` + * ({@link emitPageComponentCopy}). Only a `kind: 'thank-you'` panel authors + * copy; any other kind records both keys unauthored (`pushOptional`), so the + * two carriers cannot read the kind differently. + */ +function pushThankYouPanel( + out: ExpectedEntry[], + root: string[], + behavior: unknown, + source: ExpectedEntry['source'], + extra?: EntryScope, +): void { + const panel = behavior && typeof behavior === 'object' ? behavior as { kind?: unknown; title?: unknown; message?: unknown } : undefined; + const thankYou = panel?.kind === 'thank-you' ? panel : undefined; + pushOptional(out, [...root, 'submitBehavior', 'title'], thankYou?.title, source, extra); + pushOptional(out, [...root, 'submitBehavior', 'message'], thankYou?.message, source, extra); } /** @@ -1348,6 +1371,11 @@ function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): v ); } } + // An `object-form`'s thank-you panel, under the form component's id — + // the key `translatePage` reads, by the form view's panel's rules. + if (component.type === PAGE_FORM_COMPONENT_TYPE) { + pushThankYouPanel(out, ['pages', name, 'components', id as string], props.submitBehavior, 'page'); + } } return component; }); diff --git a/packages/cli/test/i18n-page-form-thank-you-coverage.test.ts b/packages/cli/test/i18n-page-form-thank-you-coverage.test.ts new file mode 100644 index 00000000000..801b096609d --- /dev/null +++ b/packages/cli/test/i18n-page-form-thank-you-coverage.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// A page `object-form` component's thank-you panel (the `title` / `message` +// of a `kind: 'thank-you'` `properties.submitBehavior`) is translated through +// `pages..components..submitBehavior.{title,message}` +// (`translatePage` overlays it, by the form view's panel's rules). The walker +// emits both slots for every `object-form` the page addresses, so +// `os i18n extract` scaffolds them and the coverage gate reports a locale +// that leaves an authored panel untranslated. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { TranslationDataSchema } from '@objectstack/spec/system'; + +const panelKeys = (config: any) => + collectExpectedEntries(config) + .map((e) => e.path.join('.')) + .filter((p) => p.startsWith('pages.') && p.includes('.submitBehavior.')); + +const form = (id: string | undefined, submitBehavior: unknown) => ({ + ...(id ? { id } : {}), + type: 'object-form', + properties: { objectName: 'project', mode: 'create', submitBehavior }, +}); + +const config = (translations: unknown[] = []) => ({ + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + objects: [{ name: 'project', label: 'Project', fields: { name: { type: 'text', label: 'Name' } } }], + pages: [ + { + name: 'project_wizard', + label: 'New Project', + type: 'app', + regions: [ + { + name: 'main', + components: [ + form('project_form', { kind: 'thank-you', title: 'Project created', message: 'Your project is ready.' }), + form('redirect_form', { kind: 'redirect', url: '/projects' }), + // No id: no address, so no slot. + form(undefined, { kind: 'thank-you', title: 'Anonymous' }), + // Not an `object-form`: no panel slot under its id. + { id: 'summary_card', type: 'page:card', properties: { title: 'Summary' } }, + { + id: 'side_card', + type: 'page:card', + properties: { title: 'Quick add', children: [form('quick_form', { kind: 'thank-you', message: 'Added.' })] }, + }, + ], + }, + ], + }, + ], + translations, +}); + +describe("i18n walker — a page `object-form`'s thank-you panel, `components..submitBehavior`", () => { + it('emits both slots for each addressed `object-form`, nested ones included, and none elsewhere', () => { + expect(panelKeys(config()).sort()).toEqual([ + 'pages.project_wizard.components.project_form.submitBehavior.message', + 'pages.project_wizard.components.project_form.submitBehavior.title', + 'pages.project_wizard.components.quick_form.submitBehavior.message', + 'pages.project_wizard.components.quick_form.submitBehavior.title', + 'pages.project_wizard.components.redirect_form.submitBehavior.message', + 'pages.project_wizard.components.redirect_form.submitBehavior.title', + ]); + }); + + it('seeds authored panel copy and leaves unauthored copy — and every non-thank-you kind — unseeded', () => { + const entries = collectExpectedEntries(config()); + const at = (p: string) => entries.find((e) => e.path.join('.') === p); + const root = 'pages.project_wizard.components'; + expect(at(`${root}.project_form.submitBehavior.title`)).toMatchObject({ sourceValue: 'Project created', inline: 'Project created' }); + expect(at(`${root}.project_form.submitBehavior.message`)).toMatchObject({ sourceValue: 'Your project is ready.' }); + expect(at(`${root}.quick_form.submitBehavior.title`)?.inline).toBeUndefined(); + expect(at(`${root}.quick_form.submitBehavior.message`)).toMatchObject({ sourceValue: 'Added.' }); + expect(at(`${root}.redirect_form.submitBehavior.title`)?.inline).toBeUndefined(); + expect(at(`${root}.redirect_form.submitBehavior.message`)?.inline).toBeUndefined(); + }); + + it('`os i18n extract` writes a skeleton the translation schema accepts', () => { + const page: any = extractTranslations(config(), { locales: ['en'] }).bundles.en?.pages?.project_wizard; + expect(page?.components?.project_form?.submitBehavior).toEqual({ title: 'Project created', message: 'Your project is ready.' }); + const parsed = TranslationDataSchema.safeParse({ pages: { project_wizard: page } }); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it('the coverage gate reports authored panel copy a supported locale leaves untranslated', () => { + const issuesOf = (translations: unknown[]) => computeI18nCoverage(config(translations)) + .issues.filter((i) => i.locale === 'zh-CN' && i.key.startsWith('pages.') && i.key.includes('.submitBehavior.')) + .map((i) => i.key) + .sort(); + expect(issuesOf([{ 'zh-CN': { objects: { project: { label: '项目' } } } }])).toEqual([ + 'pages.project_wizard.components.project_form.submitBehavior.message', + 'pages.project_wizard.components.project_form.submitBehavior.title', + 'pages.project_wizard.components.quick_form.submitBehavior.message', + ]); + expect(issuesOf([{ + 'zh-CN': { + pages: { + project_wizard: { + components: { + project_form: { submitBehavior: { title: '项目已创建', message: '您的项目已就绪。' } }, + quick_form: { submitBehavior: { message: '已添加。' } }, + }, + }, + }, + }, + }])).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 7c5db949171..5d6dc18f0b0 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -2490,6 +2490,80 @@ describe('validateTranslationReferences — `page:tabs` item labels (`components }); }); +describe("validateTranslationReferences — an `object-form`'s thank-you panel (`components..submitBehavior`)", () => { + /** + * The showcase wizard's shape: a page whose `object-form` authors a + * thank-you panel, beside a card that nests a second form, so each verdict + * below is judged against the same metadata as the clean run and differs + * only in the bundle. + */ + const wizardStack = { + pages: [ + { + name: 'project_wizard', + label: 'New Project', + regions: [{ + name: 'main', + components: [ + { + type: 'object-form', + id: 'project_form', + properties: { objectName: 'project', submitBehavior: { kind: 'thank-you', title: 'Project created' } }, + }, + { + type: 'page:card', + id: 'side_card', + properties: { + title: 'Quick add', + children: [{ type: 'object-form', id: 'quick_form', properties: { objectName: 'project' } }], + }, + }, + ], + }], + }, + ], + }; + const bundle = (components: unknown) => + ({ ...wizardStack, translations: [{ 'zh-CN': { pages: { project_wizard: { components } } } }] }); + + it('reports nothing for a panel over a declared `object-form`, nested ones included', () => { + expect(validateTranslationReferences(bundle({ + project_form: { title: '创建项目', submitBehavior: { title: '项目已创建' } }, + quick_form: { submitBehavior: { message: '已添加。' } }, + }))).toEqual([]); + }); + + it('refuses a panel on a component of another type, by name', () => { + const findings = validateTranslationReferences(bundle({ side_card: { submitBehavior: { title: 'x' } } })); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.project_wizard.components.side_card.submitBehavior'); + expect(findings[0].message).toBe( + 'Translations carry a thank-you panel for "side_card", which page "project_wizard" declares as a ' + + '`page:card`, not an `object-form`, so nothing resolves it.', + ); + expect(findings[0].hint).toBe('Thank-you panel translations go under the `id` of an `object-form` component.'); + }); + + it('refuses a panel under a component id the page does not declare, with the nearest form id offered', () => { + const findings = validateTranslationReferences(bundle({ project: { submitBehavior: { title: 'x' } } })); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.project_wizard.components.project.submitBehavior'); + expect(findings[0].message).toBe( + 'Translations carry a thank-you panel for "project", a component id page "project_wizard" does not declare, ' + + 'so nothing resolves it. Did you mean "project_form"?', + ); + }); + + it('control: a page this stack does not declare is not judged by this check', () => { + expect(validateTranslationReferences({ + ...wizardStack, + translations: [{ 'zh-CN': { pages: { other_page: { components: { f: { submitBehavior: { title: 'x' } } } } } } }], + })).toEqual([]); + }); +}); + describe('validateTranslationReferences — flow toasts and screen-field options (#22507)', () => { /** * hotclm's measured shape, trimmed: a screen flow that authors a completion diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 107b5f1e951..d6d311758b1 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -247,6 +247,7 @@ import { APPROVAL_NODE_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/ import { FLOW_TERMINAL_MESSAGE_KEYS, deriveImplicitPackageBook, + PAGE_FORM_COMPONENT_TYPE, PAGE_TABS_COMPONENT_TYPE, hasPlatformObjectPrefix, isPlatformProvidedObjectName, @@ -571,7 +572,7 @@ interface Universe { pages: Map>; } -/** What the `items` check knows about one addressed page component. */ +/** What the `items` and `submitBehavior` checks know about one addressed page component. */ interface PageComponentFacts { type: string | undefined; /** The `page:tabs` item `value`s (`pageTabItemKey`); empty for any other type. */ @@ -1398,7 +1399,8 @@ function buildUniverse(stack: AnyRec): Universe { for (const page of artifactProvidedRecords(stack, 'pages')) collectPageRecord(page); // ── Pages: the components `pages..components.` addresses, for the - // `page:tabs` item key (`components..items..label`) ── + // `page:tabs` item key (`components..items..label`) and the + // `object-form` panel key (`components..submitBehavior`) ── // // Walked with spec's `walkAddressedPageComponents`, NOT lint's // `walkPageComponents`: the key is read by `translatePage`, which runs that @@ -2174,18 +2176,47 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind } } - // ── pages..components..items. ──────────────────── + // ── pages..components..items. / .submitBehavior ── // - // Only the `items` member is judged here: it is the one page key whose - // target this check can see whole — a declared tabs component's item - // `value`s. A page this stack does not declare is skipped (no rung + // Only the `items` and `submitBehavior` members are judged here: they + // are the two page keys whose target this check can see whole — a + // declared tabs component's item `value`s, and a declared + // `object-form`. A page this stack does not declare is skipped (no rung // judges `pages.` itself yet), and so is every other member. for (const [pageName, rawPage] of Object.entries(asRecord(rawData.pages))) { const page = universe.pages.get(pageName); if (!page || !isRec(rawPage)) continue; const pagePath = `${base}.pages.${pageName}`; for (const [componentId, rawComponent] of Object.entries(asRecord(rawPage.components))) { - if (!isRec(rawComponent) || rawComponent.items === undefined) continue; + if (!isRec(rawComponent)) continue; + // An `object-form`'s thank-you panel: `translatePage` reads the key + // under a declared `object-form`'s id only, so one under any other + // component (or under an id the page does not declare) resolves + // nothing. + if (rawComponent.submitBehavior !== undefined) { + const panelPath = `${pagePath}.components.${componentId}.submitBehavior`; + const component = page.get(componentId); + const formHint = 'Thank-you panel translations go under the `id` of an `object-form` component.'; + if (!component) { + orphan( + `${inLocale} · page "${pageName}" · component "${componentId}"`, + panelPath, + `Translations carry a thank-you panel for "${componentId}", a component id page "${pageName}" ` + + `does not declare, so nothing resolves it.` + + suggest(componentId, [...page].filter(([, c]) => c.type === PAGE_FORM_COMPONENT_TYPE).map(([id]) => id)), + formHint, + ); + } else if (component.type !== PAGE_FORM_COMPONENT_TYPE) { + orphan( + `${inLocale} · page "${pageName}" · component "${componentId}"`, + panelPath, + `Translations carry a thank-you panel for "${componentId}", which page "${pageName}" declares as a ` + + `\`${component.type ?? 'component'}\`, not an \`object-form\`, so nothing resolves it.`, + formHint, + ); + } + } + if (rawComponent.items === undefined) continue; const itemsPath = `${pagePath}.components.${componentId}.items`; const where = `${inLocale} · page "${pageName}" · component "${componentId}"`; const tabsHint = 'Tab-item translations go under the `id` of a `page:tabs` component.'; From f00165558a95cd4e313ab0988af411623a54343f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:41:36 +0000 Subject: [PATCH 3/5] feat(showcase): the new-project wizard's form gets an id and its zh-CN copy Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyMTiYRPdRNbNBCGdF --- .../app-showcase/src/system/translations/index.ts | 14 ++++++++++++++ .../src/ui/pages/new-project-wizard.page.ts | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 791d67bcb06..d52702e54d2 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -1253,6 +1253,20 @@ export const ShowcaseTranslationBundle = { project_picker: { label: '项目', placeholder: '选择项目…' }, }, }, + // `ui/pages/new-project-wizard.page.ts` — the create wizard's form, its + // heading and the thank-you panel it swaps in after the create. + showcase_new_project_wizard: { + components: { + new_project_form: { + title: '创建项目', + description: '三步向导:先填基本信息,再看健康状况,最后是预算与排期。', + submitBehavior: { + title: '项目已创建', + message: '新项目已就绪——可在「项目」中找到它,或重新打开此向导再创建一个。', + }, + }, + }, + }, // Nested per-component copy. These ids sit inside a container's // `properties.children`, which `translatePage` resolves (#12961) but // `os i18n extract` could not see until #13109 matched the extractor's diff --git a/examples/app-showcase/src/ui/pages/new-project-wizard.page.ts b/examples/app-showcase/src/ui/pages/new-project-wizard.page.ts index 5bbe4cb5617..d242c1715bb 100644 --- a/examples/app-showcase/src/ui/pages/new-project-wizard.page.ts +++ b/examples/app-showcase/src/ui/pages/new-project-wizard.page.ts @@ -23,6 +23,10 @@ export const NewProjectWizardPage = definePage({ name: 'main', components: [ { + // The id is the form's translation address: its `title`, + // `description` and thank-you panel are keyed under + // `pages.showcase_new_project_wizard.components.new_project_form`. + id: 'new_project_form', type: 'object-form', properties: { objectName: 'showcase_project', From 2093ae5e4819937614e64c7c067c8f673d05594e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:46:48 +0000 Subject: [PATCH 4/5] chore: changeset and regenerated api-surface for the page form panel key Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyMTiYRPdRNbNBCGdF --- .../28214-page-form-thank-you-translation.md | 17 +++++++++++++++++ packages/spec/api-surface/system.json | 1 + packages/spec/export-origins/system.json | 1 + 3 files changed, 19 insertions(+) create mode 100644 .changeset/28214-page-form-thank-you-translation.md diff --git a/.changeset/28214-page-form-thank-you-translation.md b/.changeset/28214-page-form-thank-you-translation.md new file mode 100644 index 00000000000..ff8d793943c --- /dev/null +++ b/.changeset/28214-page-form-thank-you-translation.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +'@objectstack/lint': minor +--- + +feat(spec): a page `object-form`'s thank-you panel is translated through `pages.PAGE.components.ID.submitBehavior.title` / `.message` + +Clause-②: yes +Metadata-protocol: added + +A page that embeds an `object-form` with `submitBehavior: { kind: 'thank-you', title, message }` used to confirm a create in the authoring language only: the strict `pages.PAGE.components.ID` translation entry declared no key for the panel, and `translatePage` never read `properties.submitBehavior`. A form view's own panel already had its key (`objects.OBJECT._views.VIEW.submitBehavior`); the page form, which takes the same member by reference, had none. + +- **The key.** `pages.PAGE.components.ID.submitBehavior` takes `title` and `message`, under the form component's own `id`, beside its `title` and `description`. It is the page's copy, so two pages binding the same object can word their panels differently. The node is the form view's panel node, built by one factory, so a `thankYou` key is refused naming `submitBehavior`, and `url` / `kind` are refused with a prescription. An `object-form` without an `id` has no key: give it one. +- **The overlay.** `translatePage` overlays the two strings onto the component's `properties.submitBehavior` through the same function `translateView` uses for a form view's panel: only a `kind: 'thank-you'` panel is translated, only copy the form authors (a key cannot invent a `message`), and a missing key keeps the authored string. A form nested in a container is reached by the id route, as every other component copy is. +- **Extraction and coverage.** `os i18n extract` scaffolds `pages.PAGE.components.ID.submitBehavior.title` / `.message` for every `object-form` the page addresses, and the coverage report counts an authored panel a supported locale leaves untranslated. +- **Lint.** `os lint` refuses a `submitBehavior` key under a component that is not an `object-form`, and under a component id the page does not declare, offering the nearest form id. diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 989dd6b1fb1..977addc3343 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -505,6 +505,7 @@ "OtelExporterType (const)", "OtelExporterType (type)", "PAGE_COMPONENT_COPY_KEYS (const)", + "PAGE_FORM_COMPONENT_TYPE (const)", "PAGE_TABS_COMPONENT_TYPE (const)", "PKG_CONVENTIONS (const)", "PLATFORM_OBJECTS_BY_PACKAGE (const)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 1b4787e7ad3..8126c8fa2f5 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -484,6 +484,7 @@ "OrganizationOwnershipSupplementSchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipSupplementSchema (const)", "OtelExporterType": "src/system/tracing.zod.ts#OtelExporterType (type)", "PAGE_COMPONENT_COPY_KEYS": "src/system/i18n-resolver.ts#PAGE_COMPONENT_COPY_KEYS (const)", + "PAGE_FORM_COMPONENT_TYPE": "src/system/i18n-resolver.ts#PAGE_FORM_COMPONENT_TYPE (const)", "PAGE_TABS_COMPONENT_TYPE": "src/system/i18n-resolver.ts#PAGE_TABS_COMPONENT_TYPE (const)", "PKG_CONVENTIONS": "src/system/constants/paths.ts#PKG_CONVENTIONS (const)", "PLATFORM_OBJECTS_BY_PACKAGE": "src/system/constants/platform-object-names.ts#PLATFORM_OBJECTS_BY_PACKAGE (const)", From 8837fd001625eb762b195ac503cac896a3edd6b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 21:56:10 +0000 Subject: [PATCH 5/5] docs(ui): name the two thank-you panel translation keys in the translations key table Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyMTiYRPdRNbNBCGdF --- content/docs/ui/translations.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 32ab06f311d..ed562f9caa9 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -72,6 +72,7 @@ export default defineStack({ | Inline child-grid heading of a master-detail field (drawn on the parent's form) | `objects..fields..inlineTitle` — keyed on the child object that owns the field; untranslated it keeps its authored text, with no fallback to the child's plural label | | Picklist option labels | `objects..fields..options.` | | View titles, descriptions, empty states | `objects.._views.` | +| A form view's thank-you panel (shown after a submit) | `objects.._views..submitBehavior.title` / `.message` — only when the form's `submitBehavior` is `kind: 'thank-you'`, and only copy the form authors: a key cannot add a `message` the form leaves to the default wording | | Bulk-action copy on a list view (button, confirm prompt, dialog fields) | `objects.._views..bulkActions..label` / `.confirmText` / `.confirmLabel` / `.params..label` / `.help` / `.placeholder` — a bulk param's hint is `help`, not `helpText` | | Action labels, confirm text, success messages, disabled reasons | `objects.._actions.` | | Action result dialogs (title / description / acknowledge / field labels) | `objects.._actions..resultDialog` | @@ -87,6 +88,7 @@ export default defineStack({ | Page labels and `page:header` copy | `pages..label` / `description` / `title` / `subtitle` — on a `kind: 'slotted'` page the header under `slots.header` is the page's header | | Page component copy, by component id | `pages..components..title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` | | Page tab labels (`page:tabs` items) | `pages..components..items..label` — keyed by the tabs component's `id` and the item's `value`; an item without a `value` has no key (localize it with an inline locale map), and `page:accordion` panels have none | +| A page `object-form`'s thank-you panel | `pages..components..submitBehavior.title` / `.message` — keyed by the `object-form` component's `id` (a form without an `id` has no key), by the same rules as a form view's panel | | Screen-flow wizards (flow label, screen headings and body text, screen field copy) | `flows..label` / `flows..screens..title` / `.description` / `.fields..label` / `.placeholder` / `.inlineHelpText` — a screen field's help text is `inlineHelpText`, the key the field itself declares, not `help`; see the boundary note below | | Hook refusal messages | `hooks..refusals..message` — one per entry of the hook's `refusals` catalogue. A sandboxed hook body refuses by code, and the server answers the refusal with this message in the caller's locale, else the declared `message` | | Position label / description | `positions..label` / `description` — keyed by the position's `name`; overlaid on every served position, which is the list the console's position picker draws |