diff --git a/.changeset/22507-flow-options-toasts-translation.md b/.changeset/22507-flow-options-toasts-translation.md index 88a1faf7af4..d7f469a47b5 100644 --- a/.changeset/22507-flow-options-toasts-translation.md +++ b/.changeset/22507-flow-options-toasts-translation.md @@ -19,4 +19,4 @@ Clause-②: yes (narrowing) - **Where it is read.** `translateFlow` (`@objectstack/spec/system`) overlays both, and `resolveFlowScreenFieldOptions(bundle, flowName, nodeId, field)` resolves a served screen field's options. The console's flow runner reads them in a following release; until then a translation is stored and the authored text is drawn. - **`objectstack validate`** warns on an option key that names no declared option of the field, or names it by its label (`translation-option-key-unknown`). - **`os i18n extract` and the coverage gate** now scaffold and demand each screen field's option labels, the failure toast of every flow that declares one, and the completion toast of a flow with a screen, in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each until it translates them. -- **Still not keyed: a screen's `description`.** It is a `{{ }}` template the server renders per run, so its translation has to be chosen before that render; a translated `description` is still refused by the schema with that reason. +- **A screen's `description`** is keyed too, in the same release, and translated by the engine rather than the console: see the entry for `flows..screens..description`. diff --git a/.changeset/22507-screen-copy-engine-pick-automation.md b/.changeset/22507-screen-copy-engine-pick-automation.md new file mode 100644 index 00000000000..7bf16d95f13 --- /dev/null +++ b/.changeset/22507-screen-copy-engine-pick-automation.md @@ -0,0 +1,12 @@ +--- +"@objectstack/service-automation": minor +--- + +feat(automation): a screen's heading and body text render in the run's locale + +Clause-②: yes (widening) + +- **What is new.** The `screen` executor serves a screen's `title` and `description` in the language of the person who started the run. For each, it asks the engine for the translated template at `flows..screens..title` / `.description` through the `i18n` service, in `AutomationContext.locale`, and fills the `{{ }}` holes of that template. This is the same pick a refusing `end` node's message already makes, through the same channel (`setI18nServiceSource`). +- **When the authored text renders.** With no locale on the run (a record-change, schedule or webhook trigger), no `i18n` service, no entry or an empty one, or a translation that does not compile, the authored template renders as before. A translation that does not compile also logs a `warn` naming the key. A resumed leg renders in the locale the run was started in. +- **The heading key covers the node label.** A screen with no `config.title` shows its node label, and a translated `title` replaces whichever heading the screen shows. The body text is translated only where the screen authors one. +- **`AutomationEngine.renderFlowTextSlot(slot, variables, context)`** is the one translated-template pick, now shared by the refusing `end` node and the `screen` executor. Its argument type, `FlowTextSlotTranslation`, is exported. diff --git a/.changeset/22507-screen-copy-engine-pick-cli.md b/.changeset/22507-screen-copy-engine-pick-cli.md new file mode 100644 index 00000000000..61268086654 --- /dev/null +++ b/.changeset/22507-screen-copy-engine-pick-cli.md @@ -0,0 +1,10 @@ +--- +"@objectstack/cli": minor +--- + +feat(cli): `os i18n extract` and the coverage gate scaffold and demand a screen's `description` + +Clause-②: yes (widening) + +- `os i18n extract` now writes `flows..screens..description` into the skeleton for every screen that authors body text, seeded with the authored `{{ }}` template so a translator keeps its holes. +- The coverage gate demands it in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each screen that authors a `description`, until it translates it. A screen with no `description` is never asked for one. diff --git a/.changeset/22507-screen-copy-engine-pick-lint.md b/.changeset/22507-screen-copy-engine-pick-lint.md new file mode 100644 index 00000000000..cdce13746d2 --- /dev/null +++ b/.changeset/22507-screen-copy-engine-pick-lint.md @@ -0,0 +1,9 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): `objectstack validate` judges a translated screen `description` + +Clause-②: yes (widening) + +- A translation of `flows..screens..description` over a screen that declares no `config.description` is reported as `translation-target-unknown` (error). The engine translates body text only where the screen authors one, so nothing would read the key. Declare the screen's `description`, or drop the key. diff --git a/.changeset/22507-screen-copy-engine-pick-spec.md b/.changeset/22507-screen-copy-engine-pick-spec.md new file mode 100644 index 00000000000..bb5d25aac6f --- /dev/null +++ b/.changeset/22507-screen-copy-engine-pick-spec.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": minor +--- + +feat(i18n): a screen's body text has a translation key, and a screen's heading and body text are translated by the engine in the run's locale (`flows..screens..description`) + +Clause-②: yes (widening) + +- **What is new.** A fully translated screen flow no longer has to keep a screen's body text in the source language. Translate it under `flows..screens..description`, beside the heading's `flows..screens..title`, whose address does not change. +- **A translation is a template.** A screen's `title` and `description` are `{{ }}` templates rendered for each run, so a translation keeps the holes of the text it translates: `description: '任务“{{ subject }}”创建成功!'` for `'Task "{{ subject }}" created successfully!'`. The schema judges a translated `description` with the same text-slot rule the source text gets, so a single-brace `{subject}` is refused with the `{{ subject }}` spelling. An empty string is accepted: it is the untranslated slot `os i18n extract` writes. +- **The body text translates only where the screen authors one.** A bundle cannot add body text to a screen that declares no `config.description`. +- **Who translates it.** The automation engine, not the console. It picks the translated template in the locale of the person who started the run and then fills the holes, so the screen a paused run serves is already translated. `ScreenSpec.title` and `ScreenSpec.description` now document that a client draws them as served and never overlays a translation on them. +- **`flowScreenCopyKey(flowName, nodeId, key)`** (`@objectstack/spec/system`) spells the key the engine reads, beside `flowRefusalMessageKey`. `FLOW_SCREEN_COPY_KEYS` is now `['title', 'description']`, and `translateFlow` overlays a translated `description` on a flow document where the screen authors one. +- **`resolveFlowScreenTitle` is not for a served screen.** A served `ScreenSpec.title` is already translated and filled, so overlaying the resolver's answer on it would draw the template's holes as literal text. The console stops calling it in a following release, and the function retires after that. +- A translated screen `title` is not judged by the text-slot rule yet, so a single-brace token in it is drawn as literal text, as it was before. diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 7749c3b7652..c84e2ac62e2 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -260,7 +260,7 @@ Translation data for a single object | **label** | `string` | optional | Translated flow label | | **successMessage** | `string` | optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | | **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | -| **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | +| **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | ### Nested Shape: `PlatformTranslationData.metadataForms[string]` @@ -461,7 +461,7 @@ Translation data for a single object | **label** | `string` | optional | Translated flow label | | **successMessage** | `string` | optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | | **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | -| **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | +| **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | ### Nested Shape: `TranslationData.metadataForms[string]` @@ -623,7 +623,7 @@ Translation data for a single object | **label** | `string` | optional | Translated flow label | | **successMessage** | `string` | optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | | **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | -| **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | +| **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | ### Nested Shape: `TranslationItem.metadataForms[string]` diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index ef3d43eb2b4..48abeebf15b 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -80,7 +80,7 @@ export default defineStack({ | Dataset dimension and measure labels | `datasets..dimensions..label` / `datasets..measures..label` | | 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` | -| Screen-flow wizards (flow label, screen headings, screen field copy) | `flows..label` / `flows..screens..title` / `.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 | +| 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 | | Global actions, messages | `globalActions`, `messages` | | Settings UI shell copy (the source badge on a settings row) | `settingsCommon.sourceLabels.` — the per-namespace settings copy under `settings` is **platform-only**: an app bundle carrying it is refused by name, and the platform's own strings are translated in `@objectstack/service-settings`'s bundle | | A label written as an inline locale map (`label: { en: 'Members', 'zh-CN': '成员' }`) | Nowhere — it is written on the metadata and resolved at render time; see **Current boundaries** below | @@ -390,23 +390,28 @@ Honest limits worth knowing before you plan around them: by rule name alone and so could not tell two objects' rules apart; the route above is object-scoped and shipped with its reader. ADR-0049's 2026-09-04 amendment carries that record. -- **The `flows` group is applied by the console's screen-flow runner, and its - face is deliberately small.** The keys are addressed the way the runner - resolves them — flow name, screen node id, screen field name — and both - halves render in the active locale: - - `screens` is applied to the screen copy: each screen's `title`, and each - field's `label`, `placeholder` and `inlineHelpText`; +- **The `flows` group is applied where each string is rendered, and its face + is deliberately small.** The keys are addressed the way the runner resolves + them — flow name, screen node id, screen field name — and every part renders + in the active locale: + - a screen's `title` and `description` are `{{ }}` templates the server + renders for each run, so the server translates them: it picks the + translated template in the locale of the person who started the run, then + fills its holes, and the console draws the screen as served. A translation + keeps the holes of the text it translates (`'任务“{{ subject }}”创建成功!'`), + and `description` is translated only where the screen authors one; + - each field's `label`, `placeholder` and `inlineHelpText` is applied by the + console's screen-flow runner; - the flow's own `label` names the flow in the runner's header, above the step's heading, and in the completion toast. A locale the bundle does not cover shows the label authored on the flow, and a backend that serves no label shows the flow's API name. - Three strings stay outside the group. A screen's `description` renders as - authored. A `successMessage` authored on the flow replaces the completion - sentence and renders as authored too, so the translated label appears in the - toast only when the flow declares none. The runner's own chrome — the Cancel - and Submit buttons — belongs to the console's message catalog rather than your - app's bundle. + Two strings stay outside the group. A `successMessage` authored on the flow + replaces the completion sentence and renders as authored, so the translated + label appears in the toast only when the flow declares none. The runner's own + chrome — the Cancel and Submit buttons — belongs to the console's message + catalog rather than your app's bundle. **The tooling asks for these keys like any other group's.** `os lint` reports a missing `flows.*` key against `supportedLocales`, and `os i18n extract` diff --git a/examples/app-crm/src/translations/crm.translation.ts b/examples/app-crm/src/translations/crm.translation.ts index 93b6b677a51..887d5e3ae60 100644 --- a/examples/app-crm/src/translations/crm.translation.ts +++ b/examples/app-crm/src/translations/crm.translation.ts @@ -218,9 +218,15 @@ export const CrmTranslationBundle = defineTranslationBundle({ successMessage: '🎉 线索已转化 — 已创建客户和商机。', errorMessage: '线索转化未完成 — 请检查线索后重试。', screens: { - screen_already_converted: { title: '已转化' }, - screen_account: { title: '第 1 步,共 2 步 · 客户' }, - screen_opportunity: { title: '第 2 步,共 2 步 · 商机' }, + screen_already_converted: { title: '已转化', description: '该线索已转化为商机。' }, + screen_account: { + title: '第 1 步,共 2 步 · 客户', + description: '核对并补全从线索带过来的客户记录。', + }, + screen_opportunity: { + title: '第 2 步,共 2 步 · 商机', + description: '添加商机及其产品明细,二者在同一个事务中一起保存。', + }, }, }, }, diff --git a/examples/app-todo/src/translations/ja-JP.ts b/examples/app-todo/src/translations/ja-JP.ts index 8b058517f2b..77b311d56be 100644 --- a/examples/app-todo/src/translations/ja-JP.ts +++ b/examples/app-todo/src/translations/ja-JP.ts @@ -112,7 +112,7 @@ export const jaJP: TranslationData = { }, }, }, - success_screen: { title: 'タスクを作成しました' }, + success_screen: { title: 'タスクを作成しました', description: 'タスク「{{ subject }}」を作成しました!' }, }, }, }, diff --git a/examples/app-todo/src/translations/zh-CN.ts b/examples/app-todo/src/translations/zh-CN.ts index 803e29c0df5..2978abb163d 100644 --- a/examples/app-todo/src/translations/zh-CN.ts +++ b/examples/app-todo/src/translations/zh-CN.ts @@ -120,7 +120,7 @@ export const zhCN: TranslationData = { }, }, }, - success_screen: { title: '任务已创建' }, + success_screen: { title: '任务已创建', description: '任务“{{ subject }}”创建成功!' }, }, }, }, diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index 988c6fd6586..857fa573e3f 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -355,8 +355,9 @@ const COVERAGE_SOURCE: Record // it, so it keeps its own bucket and reports as `i18n/missing-picklist`. picklist: 'picklist', page: 'page', - // Screen-flow copy (`flows..label`, `flows..screens..title`, and - // the per-field `label` / `placeholder` / option labels), the terminal + // Screen-flow copy (`flows..label`, `flows..screens..title` / + // `.description` (#22507), and the per-field `label` / `placeholder` / + // option labels), the terminal // toasts (`flows..successMessage` / `.errorMessage`, #22507) and a refused // `end` node's message (`flows..refusals..message`, #22450) — the // author's own text, so diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index dfc3189f601..fd3cdcd003c 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -105,6 +105,7 @@ * pages..components.. (per-component copy, #6080) * flows..label * flows..screens..title (#7646 / #11287) + * flows..screens..description (#22507) * flows..screens..fields..label * flows..screens..fields..placeholder * flows..screens..fields..inlineHelpText @@ -1791,10 +1792,15 @@ function collectFlowNodesDeep(nodes: unknown): any[] { * * - **A screen's `title` falls back to the node `label`.** The executor builds * the wire title as `config.title ?? node.label` (`ScreenSpec.title`), and - * `translateFlow` overlays the bundle onto `config.title` for that reason — - * one key covers whichever of the two the runner draws. So the seed, and the - * `inline` the coverage gate judges, is that same pair: a screen with only a - * canvas label still shows English text a translator owes a translation for. + * the engine picks the translated `title` template for whichever of the two + * it renders (#22507) — one key covers both. So the seed, and the `inline` + * the coverage gate judges, is that same pair: a screen with only a canvas + * label still shows English text a translator owes a translation for. + * - **A screen's `description` is asked for only where it is authored.** The + * engine translates body text only where the screen authors one (#22507), so + * the seed is the authored TEMPLATE, holes and all — a translator keeps the + * `{{ }}` holes, and the schema judges the translation as the same text + * slot, like a refusal message. * - **A field's `label` falls back to its `name`.** `ScreenFieldConfig.label` * is optional and forwarded as-is (`ScreenFieldSpec.label`), so the runner * renders the field name when the author wrote no label. That is a derived @@ -1887,6 +1893,11 @@ function walkScreenFlows(config: any, out: ExpectedEntry[]): void { const authored = key === 'title' ? (asAuthoredLabel(cfg[key]) ?? asAuthoredLabel(node.label)) : asAuthoredLabel(cfg[key]); + // [#22507] Body text is read only where the screen authors one (the + // engine never adds it), so an unauthored `description` is not even a + // seed-less entry: a bundle externalizing one would otherwise be + // demanded in every locale for a string nothing shows. + if (key === 'description' && authored === undefined) continue; pushOptional(out, [...screenRoot, key], authored, 'flow', scope); } diff --git a/packages/cli/test/i18n-flow-screen-coverage.test.ts b/packages/cli/test/i18n-flow-screen-coverage.test.ts index 021cd4bf402..8dbebc60b61 100644 --- a/packages/cli/test/i18n-flow-screen-coverage.test.ts +++ b/packages/cli/test/i18n-flow-screen-coverage.test.ts @@ -84,7 +84,8 @@ const leadConversion = { label: 'Conversion Details Step', config: { title: 'Conversion Details', - // Body text is guidance-REFUSED by the schema: it must not become a key. + // Body text is a key since #22507 (the engine picks its translated + // template in the run's locale), demanded only where it is authored. description: 'Choose what this lead becomes.', fields: [ { name: 'create_opportunity', label: 'Create Opportunity?', type: 'boolean' }, @@ -139,6 +140,7 @@ describe('the screen-flow gap a green i18n gate could not see (#11485)', () => { // The exact strings the console renders in English. expect(keys).toContain('flows.lead_conversion.label'); expect(keys).toContain('flows.lead_conversion.screens.conversion_details.title'); + expect(keys).toContain('flows.lead_conversion.screens.conversion_details.description'); expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.create_opportunity.label'); expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.opportunity_name.label'); expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.opportunity_name.placeholder'); @@ -170,6 +172,7 @@ describe('the screen-flow gap a green i18n gate could not see (#11485)', () => { screens: { conversion_details: { title: '转化详情', + description: '选择这条线索转化成什么。', fields: { create_opportunity: { label: '创建商机?' }, opportunity_name: { label: '商机名称', placeholder: 'Acme - 第三季度续约', inlineHelpText: '显示在报价单上' }, @@ -198,6 +201,7 @@ describe('what the walker harvests from a screen flow', () => { it('keys screens by `FlowNode.id` and fields by `ScreenFieldConfig.name`', () => { expect(flowKeys({ flows: [leadConversion] }).sort()).toEqual([ 'flows.lead_conversion.label', + 'flows.lead_conversion.screens.conversion_details.description', 'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.inlineHelpText', 'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.label', 'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.placeholder', @@ -266,9 +270,7 @@ describe('what the walker harvests from a screen flow', () => { it('never emits the keys the schema refuses by name', () => { const keys = flowKeys({ flows: [leadConversion] }); - // `description` is a screen's body text — guidance-refused spec-side. - expect(keys.filter((k) => k.endsWith('.description'))).toEqual([]); - // Runner chrome and per-field `help` / `options` are refused too. + // Runner chrome and per-field `help` / `options` are refused. expect(keys.filter((k) => /\.(help|helpText|options|successMessage|errorMessage)$/.test(k))).toEqual([]); }); }); @@ -650,6 +652,54 @@ const guardedCapture = { edges: [], }; +describe('a screen\'s body text is a key where the screen authors one (#22507)', () => { + // Ruling A on #22507: the engine picks the translated `description` template + // in the run's locale before it fills the holes, so the walker seeds the + // authored TEMPLATE, holes and all, and demands it only where it is authored + // — the engine never adds body text the author did not write. + const templated = { + name: 'quick_add_task', + label: 'Quick Add Task', + type: 'screen', + nodes: [ + { id: 'success_screen', type: 'screen', label: 'Success', config: { title: 'Task Created', description: 'Task "{{ subject }}" created successfully!', waitForInput: true } }, + { id: 'bare', type: 'screen', label: 'Bare', config: { waitForInput: true } }, + ], + }; + const config = (zh?: Record) => ({ + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + flows: [templated], + translations: [{ 'zh-CN': { flows: { quick_add_task: { label: '快速添加任务', screens: { success_screen: { title: '任务已创建', ...zh }, bare: { title: '空白' } } } } } }], + }); + + it('demands the authored body text and nothing for a screen that authors none', () => { + const keys = userIssues(computeI18nCoverage(config())).filter((i) => i.locale === 'zh-CN').map((i) => i.key); + expect(keys).toEqual(['flows.quick_add_task.screens.success_screen.description']); + }); + + it('goes quiet once the body text template is translated', () => { + expect(userIssues(computeI18nCoverage(config({ description: '任务“{{ subject }}”已创建。' })))).toEqual([]); + }); + + it('is no entry at all for a screen without body text — a bundle carrying one is demanded nowhere', () => { + // The engine never reads it there, so the key is not even a seed-less + // entry: an externalized `description` must not be demanded per locale. + const keys = collectExpectedEntries({ flows: [templated] }).map((e) => e.path.join('.')); + expect(keys).toContain('flows.quick_add_task.screens.success_screen.description'); + expect(keys).not.toContain('flows.quick_add_task.screens.bare.description'); + }); + + it('scaffolds the authored template, holes and all, and no row for the screen without one', () => { + const { bundles } = extractTranslations({ flows: [templated] }, { locales: ['en', 'zh-CN'] }); + const en = bundles.en as any; + expect(en.flows.quick_add_task.screens.success_screen.description).toBe('Task "{{ subject }}" created successfully!'); + expect((bundles['zh-CN'] as any).flows.quick_add_task.screens.success_screen.description).toBe(''); + expect(en.flows.quick_add_task.screens.bare).not.toHaveProperty('description'); + const parsed = TranslationDataSchema.safeParse(en); + expect(parsed.success ? [] : parsed.error.issues).toEqual([]); + }); +}); + describe('the flow label is demanded only for a flow the runner can open', () => { it('emits no `flows..label` entry for a flow with no screen node', () => { expect(flowKeys({ flows: [nightlyDigest] })).toEqual([]); diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 1a4d4d1e78e..ef6f5f462d7 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -2309,6 +2309,27 @@ describe('validateTranslationReferences — flow toasts and screen-field options 'Option translations are keyed under screen field "notes" of screen "details" in flow "contract_intake", which declares no `options`.', ); }); + + it('reports nothing for a translated `description` over a screen that authors one (#22507)', () => { + const flow = structuredClone(intakeStack.flows[0]!) as { nodes: Array<{ id: string; config?: Record }> }; + flow.nodes.find((n) => n.id === 'details')!.config!.description = 'Review {{ record.name }}.'; + const withBody = { + flows: [flow], + translations: [{ 'zh-CN': { flows: { contract_intake: { screens: { details: { description: '请核对 {{ record.name }}。' } } } } } }], + }; + expect(validateTranslationReferences(withBody)).toEqual([]); + }); + + it('refuses a translated `description` over a screen that authors none — a bundle cannot add body text (#22507)', () => { + const findings = validateTranslationReferences(bundle({ contract_intake: { screens: { details: { description: '请核对。' } } } })); + 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"].flows.contract_intake.screens.details.description'); + expect(firstSentence(findings[0].message)).toBe( + 'Translations carry `description` for screen "details" of flow "contract_intake", which declares no `config.description`.', + ); + }); }); describe('validateTranslationReferences — namespaces deliberately not judged', () => { diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 72468db3091..edd1ad55399 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -102,6 +102,12 @@ * `flows..label` and `.screens..title` are leaf copy on a node that * resolved, so they need no check of their own — the schema closes the leaf * vocabulary (`.strict()`), which is a shape concern, not a reference. + * `.screens..description` (#22507) is judged against the screen itself: + * the engine picks a translated body text only where the screen authors a + * `config.description`, so a key over a screen that declares none is an + * orphan, `translation-target-unknown`, the toasts' rule one level down. Its + * `{{ }}` holes are the schema's to judge (`TranslationDataSchema`, the one + * text-slot judge). * * `flows..refusals..message` (#22450) is judged the same way one * level down: the node id must name an `end` node declaring @@ -359,6 +365,13 @@ interface ScreenFacts { * "say where it belongs" redirect a misfiled `globalActions` key gets. */ objectName?: string; + /** + * [#22507] Whether the screen AUTHORS body text (`config.description`, a + * non-empty string). The engine translates a screen's body text only where + * the screen authors one, so a `…screens..description` key over a + * screen without one resolves nothing. + */ + hasDescription: boolean; } /** Everything a `flows..…` key may legally name under one flow. */ @@ -1412,7 +1425,12 @@ function buildUniverse(stack: AnyRec): Universe { const options = readScreenFieldOptions(field); if (options) fieldOptions.set(name, options); } - screens.set(nodeId, { fields, fieldOptions, objectName: strName(config?.objectName) }); + screens.set(nodeId, { + fields, + fieldOptions, + objectName: strName(config?.objectName), + hasDescription: typeof config?.description === 'string' && config.description.length > 0, + }); } flows.set(flowName, { screens, refusals, messages, otherNodes }); }; @@ -1837,6 +1855,20 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind continue; } if (!isRec(rawScreen)) continue; + // ── …screens..description (#22507) ─────────────────── + // The engine translates the body text only where the screen + // authors one, so a translation over a screen with none resolves + // nothing — the toast rule one level down. + if (rawScreen.description !== undefined && !screen.hasDescription) { + orphan( + `${inLocale} · flow "${flowName}" · screen "${nodeId}" · description`, + `${screenPath}.description`, + `Translations carry \`description\` for screen "${nodeId}" of flow "${flowName}", which ` + + `declares no \`config.description\`. Body text is translated only where the screen ` + + `authors one, so nothing resolves this key and the screen shows no body text.`, + `Declare \`config.description\` on the screen, or drop the key.`, + ); + } for (const [fieldName, rawField] of Object.entries(asRecord(rawScreen.fields))) { if (screen.fields.has(fieldName)) { // [#22507] …fields..options., judged against the diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 8719d666932..5ac2066c0be 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -4,6 +4,7 @@ import type { PluginContext } from '@objectstack/core'; import { defineActionDescriptor, ScreenConfigSchema, ScriptConfigSchema } from '@objectstack/spec/automation'; import type { ScreenConfigParsed, ScriptConfigParsed } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; +import { flowScreenCopyKey, type FlowScreenCopyKey } from '@objectstack/spec/system'; import { interpolate, renderTextSlot } from './template.js'; import { parseNodeConfig } from './parse-config.js'; import { judgeHeadlessScreen } from '../screen-input-contract.js'; @@ -170,7 +171,44 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext // The rest keep the single-brace dialect (`{account_id}`): `recordId` // names a record and `defaults` / a field's `defaultValue` hand their // resolved values over, not text. - const text = (v: unknown): string | undefined => renderTextSlot(v, variables); + // + // [#22507] …in the run's language. Maintainer ruling A: a user-read + // flow string the server renders per run is translated where it is + // rendered, in the run's locale, before its holes are filled, and a + // client overlay never touches a server-rendered slot. So each slot + // asks the engine for its translated TEMPLATE at + // `flows..screens..{title,description}` + // (`flowScreenCopyKey`) — the refusing `end` node's pick, through the + // one `i18n` channel `setI18nServiceSource` attaches — and renders the + // authored template when none is picked. The served `ScreenSpec` + // arrives translated and filled. + // - The heading is asked for even with no `config.title`: the one + // `title` key covers the node label the heading falls back to. + // - The body text is asked for only where the screen authors one: a + // bundle never adds body text the author did not write. + // `context.flowName` is stamped by the engine at run setup; a call + // with none (an executor driven outside a run) renders as authored. + const flowName = typeof context.flowName === 'string' && context.flowName.length > 0 + ? context.flowName + : undefined; + const screenText = (key: FlowScreenCopyKey, authored: unknown, shownAs: string): string | undefined => { + if (flowName === undefined) return renderTextSlot(authored, variables); + return engine.renderFlowTextSlot( + { + key: flowScreenCopyKey(flowName, node.id, key), + authored, + subject: `flow '${flowName}' screen '${node.id}'`, + shownAs, + }, + variables, + context, + ); + }; + const heading = (): string | undefined => screenText('title', cfg.title, 'the heading'); + const body = (): string | undefined => + typeof cfg.description === 'string' && cfg.description.length > 0 + ? screenText('description', cfg.description, 'the body text') + : undefined; const ref = (v: unknown): string | undefined => { const resolved = interpolate(v, variables, context); return resolved == null ? undefined : String(resolved); @@ -198,8 +236,8 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext screen: { nodeId: node.id, kind: 'object-form', - title: text(cfg.title) ?? node.label ?? objectName, - description: text(cfg.description), + title: heading() ?? node.label ?? objectName, + description: body(), objectName, mode: cfg.mode === 'edit' ? 'edit' : 'create', recordId: cfg.recordId != null ? ref(cfg.recordId) : undefined, @@ -282,8 +320,8 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext suspend: true, screen: { nodeId: node.id, - title: text(cfg.title) ?? node.label ?? 'Input', - description: text(cfg.description), + title: heading() ?? node.label ?? 'Input', + description: body(), fields, }, }; diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index d17b32e3e67..3e00d5ca260 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -1324,14 +1324,34 @@ function isRefusalSignal(err: unknown): err is FlowRefusalSignal { /** * [#22450] The slice of `II18nService` (`@objectstack/spec/contracts`) a - * refusing `end` node reads: `t()` for the translated template, and - * `getLocales()`, when the service offers it, to negotiate the run's locale. + * flow text slot reads ({@link AutomationEngine.renderFlowTextSlot}): `t()` for + * the translated template, and `getLocales()`, when the service offers it, to + * negotiate the run's locale. Named for its first reader, the refusing `end` + * node; the `screen` executor's heading and body text read it too (#22507). */ export interface RefusalI18nService { t(key: string, locale: string, params?: Record): string; getLocales?(): string[]; } +/** + * [#22507] One user-read flow text slot {@link AutomationEngine.renderFlowTextSlot} + * renders in the run's language. + */ +export interface FlowTextSlotTranslation { + /** + * The slot's key in the `flows` translation face, as `@objectstack/spec/system` + * spells it — `flowRefusalMessageKey`, `flowScreenCopyKey`. + */ + readonly key: string; + /** The authored `{{ }}` template, rendered whenever no translation is picked. */ + readonly authored: unknown; + /** Whose slot it is, for the log line — `flow 'dedupe' refused at node 'finish'`. */ + readonly subject: string; + /** What shows the authored text in its place, for the log line — `the refusal`. */ + readonly shownAs: string; +} + /** * [#22450] The locale `service` can actually ANSWER in, for the locale the run * asked for. `AutomationContext.locale` is the door's `ExecutionContext.locale`, @@ -11060,10 +11080,46 @@ export class AutomationEngine implements IAutomationService { * The run stores the refusal already rendered * (`sys_automation_run.refusal_message`, `AutomationResult.refusalMessage`), * so the translation has to be picked HERE, as a template, before its - * `{{ }}` holes are filled. This is the validation-message precedent - * (objectql's `authoredRuleMessage`) on the automation side: + * `{{ }}` holes are filled — {@link renderFlowTextSlot}, at + * `flowRefusalMessageKey` (`@objectstack/spec/system`). This is the + * validation-message precedent (objectql's `authoredRuleMessage`) on the + * automation side. + */ + private renderRefusalMessage( + flowName: string, + nodeId: string, + authored: string | undefined, + variables: Map, + context: AutomationContext, + ): string | undefined { + return this.renderFlowTextSlot( + { + key: flowRefusalMessageKey(flowName, nodeId), + authored, + subject: `flow '${flowName}' refused at node '${nodeId}'`, + shownAs: 'the refusal', + }, + variables, + context, + ); + } + + /** + * [#22450, #22507] Render one user-read flow TEXT slot in the run's + * language: pick the translated TEMPLATE at `slot.key`, then fill its + * `{{ }}` holes through `renderTextSlot`, the one text renderer — or render + * the authored template when no translation is picked. + * + * The rule it implements is stated once, in the maintainer's ruling on + * #22507: a user-read flow string the server renders per run is translated + * where it is rendered, in the run's locale, before its holes are filled, + * and a client overlay never touches a server-rendered slot. Its callers + * are the refusing `end` node ({@link renderRefusalMessage}) and the + * `screen` executor (its heading and body text, at `flowScreenCopyKey`). + * One pick for all of them: * - * - the address is `flowRefusalMessageKey` (`@objectstack/spec/system`); + * - the address is the caller's key in the `flows` translation face + * (`@objectstack/spec/system` spells each one); * - the channel is the existing `i18n` service, reached through * {@link setI18nServiceSource} — no second i18n path; * - the locale is `AutomationContext.locale`, the door's already-resolved @@ -11073,49 +11129,48 @@ export class AutomationEngine implements IAutomationService { * The authored template renders whenever no translation is picked: no * locale on the run (a record-change or schedule trigger, code calling * `execute` directly), no service, no entry (`t()` echoes the key on a - * miss), or a service that throws. A resumed leg renders in the locale the - * run was STARTED in — the context is persisted with the suspended run and a - * resume carries none of its own. + * miss; an empty skeleton slot reads as none), or a service that throws. A + * resumed leg renders in the locale the run was STARTED in — the context is + * persisted with the suspended run and a resume carries none of its own. * - * A translation is a text slot like the message it translates: the + * A translation is a text slot like the template it translates: the * single-brace `{token}` is refused at every door that reads the bundle - * (`TranslationDataSchema`, the one text-slot judge). If one still fails to - * compile here, it is a translator's defect, not the run's: the refusal - * must not become a failure, so the authored message renders and the line - * below names the key and the reason. - */ - private renderRefusalMessage( - flowName: string, - nodeId: string, - authored: string | undefined, + * (`TranslationDataSchema`, the one text-slot judge) for the keys that + * judge covers. If one still fails to compile here, it is a translator's + * defect, not the run's: the run must not fail on it, so the authored + * template renders and the line below names the key and the reason. A + * failure of the AUTHORED template still throws, exactly as before. + */ + renderFlowTextSlot( + slot: FlowTextSlotTranslation, variables: Map, context: AutomationContext, ): string | undefined { - const translated = this.translatedRefusalTemplate(flowName, nodeId, context.locale); + const translated = this.translatedFlowTemplate(slot, context.locale); if (translated !== undefined) { try { return renderTextSlot(translated.template, variables); } catch (err: unknown) { if (!(err instanceof FlowTextTemplateError)) throw err; this.logger.warn( - `[automation] flow '${flowName}' refused at node '${nodeId}': the '${translated.locale}' translation ` + - `at '${translated.key}' does not compile (${err.message}), so the refusal shows the authored ` + - `message instead. Fix the translation's holes to match the authored message.`, + `[automation] ${slot.subject}: the '${translated.locale}' translation at '${slot.key}' does not ` + + `compile (${err.message}), so ${slot.shownAs} shows its authored text instead. Fix the ` + + `translation's holes to match the authored text.`, ); } } - return renderTextSlot(authored, variables); + return renderTextSlot(slot.authored, variables); } /** - * [#22450] The translated refusal TEMPLATE for `locale`, or `undefined` when - * none is picked — see {@link renderRefusalMessage} for every reason. + * [#22450, #22507] The translated TEMPLATE at `slot.key` for `requested`, or + * `undefined` when none is picked — see {@link renderFlowTextSlot} for + * every reason. */ - private translatedRefusalTemplate( - flowName: string, - nodeId: string, + private translatedFlowTemplate( + slot: FlowTextSlotTranslation, requested: string | undefined, - ): { template: string; key: string; locale: string } | undefined { + ): { template: string; locale: string } | undefined { if (typeof requested !== 'string' || requested.length === 0) return undefined; let service: RefusalI18nService | undefined; try { @@ -11125,17 +11180,17 @@ export class AutomationEngine implements IAutomationService { } if (!service || typeof service.t !== 'function') return undefined; const locale = negotiatedServiceLocale(service, requested); - const key = flowRefusalMessageKey(flowName, nodeId); + const { key } = slot; try { const template = service.t(key, locale); // II18nService echoes the key back on a miss. if (typeof template === 'string' && template.length > 0 && template !== key) { - return { template, key, locale }; + return { template, locale }; } } catch (err: unknown) { this.logger.warn( - `[automation] flow '${flowName}' refused at node '${nodeId}': the i18n service threw reading ` + - `'${key}' (${err instanceof Error ? err.message : String(err)}), so the refusal shows the authored message.`, + `[automation] ${slot.subject}: the i18n service threw reading '${key}' ` + + `(${err instanceof Error ? err.message : String(err)}), so ${slot.shownAs} shows its authored text.`, ); } return undefined; diff --git a/packages/services/service-automation/src/index.ts b/packages/services/service-automation/src/index.ts index be3077d9145..20ca358d1d5 100644 --- a/packages/services/service-automation/src/index.ts +++ b/packages/services/service-automation/src/index.ts @@ -82,6 +82,9 @@ export type { // [#22450] What the reader `AutomationEngine.setI18nServiceSource` takes // answers — barrel-reachable through that method. RefusalI18nService, + // [#22507] What `AutomationEngine.renderFlowTextSlot` takes — the slot a + // builtin executor hands the engine's one translated-template pick. + FlowTextSlotTranslation, } from './engine.js'; // [#11997] ADR-0005 overlay precedence for same-named flow definitions. The boot diff --git a/packages/services/service-automation/src/screen-copy-translation.test.ts b/packages/services/service-automation/src/screen-copy-translation.test.ts new file mode 100644 index 00000000000..d1f233436f9 --- /dev/null +++ b/packages/services/service-automation/src/screen-copy-translation.test.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22507 — a screen's heading and body text render in the run's locale. + * + * `flows..screens..title` / `.description` are the keys; this + * file pins their reader. Both slots are `{{ }}` templates the screen executor + * renders per run, and the client receives the rendered strings, so the + * maintainer's ruling A states the rule once: a user-read flow string the + * server renders per run is translated where it is rendered, in the run's + * locale, before its holes are filled. The executor therefore asks the engine + * for the translated TEMPLATE (`AutomationEngine.renderFlowTextSlot`, the pick + * the refusing `end` node makes, `end-node-refusal-translation.test.ts`) + * through the one `i18n` channel `setI18nServiceSource` attaches, in + * `AutomationContext.locale`, and only then fills the holes. + * + * The i18n service here is the REAL in-memory implementation + * (`createMemoryI18n`, `@objectstack/core`), loaded the way `AppPlugin` loads a + * stack's bundles — not a double that answers whatever the test wants. + * + * ⚠️ Direction, predicted before running: every "translated" pin is RED on the + * unfixed executor (it rendered the authored English template whatever the + * locale); every "authored" pin is green on both sides on purpose — they fence + * the fallback. + */ + +import { describe, it, expect } from 'vitest'; +import { createMemoryI18n } from '@objectstack/core'; +import type { AutomationContext } from '@objectstack/spec/contracts'; +import { flowScreenCopyKey } from '@objectstack/spec/system'; + +import { AutomationEngine } from './engine.js'; +import { InMemorySuspendedRunStore } from './suspended-run-store.js'; +import { installBuiltinNodes } from './builtin/index.js'; +import { i18nServiceReader } from './plugin.js'; + +interface LogLine { level: string; message: string } + +function createTestLogger(lines: LogLine[] = []): any { + const at = (level: string) => (message: unknown) => { lines.push({ level, message: String(message) }); }; + return { info: at('info'), warn: at('warn'), error: at('error'), debug: at('debug'), child: () => createTestLogger(lines) }; +} + +const ACME = { id: 'rec_1', name: 'Acme Corp' } as const; + +/** The context a door hands the engine — `locale` is the request's resolved one. */ +function doorContext(locale?: string): AutomationContext { + return { + event: 'manual', object: 'account', record: { ...ACME }, userId: 'usr_1', + ...(locale ? { locale } : {}), + } as unknown as AutomationContext; +} + +/** + * `start → confirm (screen) → done (screen)`: two message-only screens, the + * first with a heading and body text that carry holes, the second reached on + * the RESUMED leg. + */ +function intakeFlow() { + return { + name: 'intake', label: 'Intake', type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'confirm', type: 'screen', label: 'Confirm Step', + config: { + waitForInput: true, + title: 'Confirm {{ record.name }}', + description: 'Review {{ record.name }} before you continue.', + }, + }, + { + id: 'done', type: 'screen', label: 'Done', + config: { waitForInput: true, title: 'Finished', description: 'All set for {{ record.name }}.' }, + }, + ], + edges: [ + { id: 'e0', source: 'start', target: 'confirm' }, + { id: 'e1', source: 'confirm', target: 'done' }, + ], + }; +} + +const ZH_BUNDLE = { + flows: { + intake: { + screens: { + confirm: { title: '确认 {{ record.name }}', description: '继续之前请核对 {{ record.name }}。' }, + done: { title: '已完成', description: '{{ record.name }} 已就绪。' }, + }, + }, + }, +}; + +/** A stack's bundles, loaded the way `AppPlugin.loadTranslations` hands them to the service. */ +function i18nWith(bundles: Record>) { + const i18n = createMemoryI18n(); + for (const [locale, data] of Object.entries(bundles)) i18n.loadTranslations(locale, data); + return i18n; +} + +function engineWith(i18n: unknown, lines: LogLine[] = []) { + const store = new InMemorySuspendedRunStore(); + const engine = new AutomationEngine(createTestLogger(lines), store); + // The plugin's own reader, over a kernel whose `i18n` service is `i18n`. + engine.setI18nServiceSource(i18nServiceReader({ + getService: ((name: string) => { + if (name === 'i18n' && i18n) return i18n as T; + throw new Error(`service '${name}' not registered`); + }) as never, + })); + installBuiltinNodes(engine, { logger: createTestLogger(lines), getService: () => undefined } as never); + return { engine, store }; +} + +describe('#22507 — the keys the screen executor asks for', () => { + it('is `flows..screens..title` / `.description`', () => { + expect(flowScreenCopyKey('intake', 'confirm', 'title')).toBe('flows.intake.screens.confirm.title'); + expect(flowScreenCopyKey('intake', 'confirm', 'description')).toBe('flows.intake.screens.confirm.description'); + }); +}); + +describe('#22507 — a zh-CN run serves the screen translated, its holes filled', () => { + it('renders the zh-CN heading and body text templates with the run\'s values', async () => { + const { engine } = engineWith(i18nWith({ 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('intake', intakeFlow() as never); + + const paused = await engine.execute('intake', doorContext('zh-CN')); + + expect(paused.status).toBe('paused'); + expect(paused.screen).toMatchObject({ + nodeId: 'confirm', + title: '确认 Acme Corp', + description: '继续之前请核对 Acme Corp。', + }); + }); + + it('negotiates the requested tag the way the bundle readers do: `zh` reaches the `zh-CN` bundle', async () => { + const { engine } = engineWith(i18nWith({ en: {}, 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('intake', intakeFlow() as never); + + const paused = await engine.execute('intake', doorContext('zh')); + + expect(paused.screen?.title).toBe('确认 Acme Corp'); + expect(paused.screen?.description).toBe('继续之前请核对 Acme Corp。'); + }); + + it('a resumed leg renders the next screen in the locale the run was STARTED in', async () => { + const { engine } = engineWith(i18nWith({ 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('intake', intakeFlow() as never); + + const paused = await engine.execute('intake', doorContext('zh-CN')); + const next = await engine.resume(paused.runId!, { variables: {} } as never); + + expect(next.status).toBe('paused'); + expect(next.screen).toMatchObject({ nodeId: 'done', title: '已完成', description: 'Acme Corp 已就绪。' }); + }); + + it('the one `title` key covers the node label a screen with no `config.title` shows', async () => { + const flow = intakeFlow(); + delete (flow.nodes[1]!.config as { title?: string }).title; + const { engine } = engineWith(i18nWith({ 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('intake', flow as never); + + const paused = await engine.execute('intake', doorContext('zh-CN')); + + expect(paused.screen?.title).toBe('确认 Acme Corp'); + }); + + it('translates an object-form screen\'s heading and body text the same way', async () => { + const flow = { + name: 'intake', label: 'Intake', type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'confirm', type: 'screen', label: 'Confirm Step', + config: { + objectName: 'account', + title: 'Confirm {{ record.name }}', + description: 'Review {{ record.name }} before you continue.', + }, + }, + ], + edges: [{ id: 'e0', source: 'start', target: 'confirm' }], + }; + const { engine } = engineWith(i18nWith({ 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('intake', flow as never); + + const paused = await engine.execute('intake', doorContext('zh-CN')); + + expect(paused.screen).toMatchObject({ + kind: 'object-form', + title: '确认 Acme Corp', + description: '继续之前请核对 Acme Corp。', + }); + }); +}); + +describe('#22507 — the authored template renders whenever no translation is picked', () => { + it('CONTROL — a locale with no entry renders the authored heading and body text', async () => { + const { engine } = engineWith(i18nWith({ en: {}, 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('intake', intakeFlow() as never); + + const paused = await engine.execute('intake', doorContext('fr-FR')); + + expect(paused.screen).toMatchObject({ + title: 'Confirm Acme Corp', + description: 'Review Acme Corp before you continue.', + }); + }); + + it('a default-locale run is unchanged — the source text it always rendered', async () => { + const { engine } = engineWith(i18nWith({ en: {}, 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('intake', intakeFlow() as never); + + const paused = await engine.execute('intake', doorContext('en')); + + expect(paused.screen).toMatchObject({ + title: 'Confirm Acme Corp', + description: 'Review Acme Corp before you continue.', + }); + }); + + it('a run with no locale (no person started it) renders the authored text', async () => { + const { engine } = engineWith(i18nWith({ 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('intake', intakeFlow() as never); + + const paused = await engine.execute('intake', doorContext()); + + expect(paused.screen?.title).toBe('Confirm Acme Corp'); + expect(paused.screen?.description).toBe('Review Acme Corp before you continue.'); + }); + + it('a composition with no `i18n` service renders the authored text', async () => { + const { engine } = engineWith(undefined); + engine.registerFlow('intake', intakeFlow() as never); + + const paused = await engine.execute('intake', doorContext('zh-CN')); + + expect(paused.screen?.title).toBe('Confirm Acme Corp'); + expect(paused.screen?.description).toBe('Review Acme Corp before you continue.'); + }); + + it('an empty skeleton slot is no translation — the authored text renders', async () => { + const skeleton = { flows: { intake: { screens: { confirm: { title: '', description: '' } } } } }; + const { engine } = engineWith(i18nWith({ 'zh-CN': skeleton })); + engine.registerFlow('intake', intakeFlow() as never); + + const paused = await engine.execute('intake', doorContext('zh-CN')); + + expect(paused.screen?.title).toBe('Confirm Acme Corp'); + expect(paused.screen?.description).toBe('Review Acme Corp before you continue.'); + }); + + it('a bundle never ADDS body text to a screen that authors none', async () => { + const flow = intakeFlow(); + delete (flow.nodes[1]!.config as { description?: string }).description; + const { engine } = engineWith(i18nWith({ 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('intake', flow as never); + + const paused = await engine.execute('intake', doorContext('zh-CN')); + + expect(paused.screen?.title).toBe('确认 Acme Corp'); + expect(paused.screen?.description).toBeUndefined(); + }); + + it('a translation that does not compile leaves the screen a screen, in the authored text, and says why', async () => { + const lines: LogLine[] = []; + const broken = { + flows: { intake: { screens: { confirm: { description: '核对 {{ record.name | no_such_formatter }}' } } } }, + }; + const { engine } = engineWith(i18nWith({ 'zh-CN': broken }), lines); + engine.registerFlow('intake', intakeFlow() as never); + + const paused = await engine.execute('intake', doorContext('zh-CN')); + + expect(paused.status).toBe('paused'); + expect(paused.screen?.description).toBe('Review Acme Corp before you continue.'); + const warning = lines.find((l) => l.level === 'warn' && l.message.includes('flows.intake.screens.confirm.description')); + expect(warning?.message).toContain("the 'zh-CN' translation"); + expect(warning?.message).toContain("flow 'intake' screen 'confirm'"); + }); +}); diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index d1cfae704f4..8fa55c8f070 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -792,6 +792,7 @@ "docAudienceAllows (function)", "emailTemplateForm (const)", "flowRefusalMessageKey (function)", + "flowScreenCopyKey (function)", "gcsStorageExample (const)", "globalFilterKey (function)", "hasMovedFileColumns (function)", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index c5fc10a4729..5166f3c0236 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 226, - "droppedRefinementSites": 688, + "droppedRefinementSites": 696, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -103,6 +103,7 @@ "manifest.sharingRules.element.sharedWith", "manifest.skills.element.triggerConditions.element", "manifest.translations.element.valueType.flows.valueType.refusals.valueType.message", + "manifest.translations.element.valueType.flows.valueType.screens.valueType.description", "manifest.views.element.form", "manifest.views.element.form.sections.element.in", "manifest.views.element.form.submitBehavior.options[1].url", @@ -236,7 +237,8 @@ }, "api/GetTranslationsResponse": { "sites": [ - "translations.flows.valueType.refusals.valueType.message" + "translations.flows.valueType.refusals.valueType.message", + "translations.flows.valueType.screens.valueType.description" ] }, "api/GetUiViewResponse": { @@ -313,6 +315,7 @@ "options[1].manifest.sharingRules.element.sharedWith", "options[1].manifest.skills.element.triggerConditions.element", "options[1].manifest.translations.element.valueType.flows.valueType.refusals.valueType.message", + "options[1].manifest.translations.element.valueType.flows.valueType.screens.valueType.description", "options[1].manifest.views.element.form", "options[1].manifest.views.element.form.sections.element.in", "options[1].manifest.views.element.form.submitBehavior.options[1].url", @@ -1162,12 +1165,14 @@ }, "system/PlatformTranslationBundle": { "sites": [ - "valueType.flows.valueType.refusals.valueType.message" + "valueType.flows.valueType.refusals.valueType.message", + "valueType.flows.valueType.screens.valueType.description" ] }, "system/PlatformTranslationData": { "sites": [ - "flows.valueType.refusals.valueType.message" + "flows.valueType.refusals.valueType.message", + "flows.valueType.screens.valueType.description" ] }, "system/Schedule": { @@ -1212,17 +1217,20 @@ }, "system/TranslationBundle": { "sites": [ - "valueType.flows.valueType.refusals.valueType.message" + "valueType.flows.valueType.refusals.valueType.message", + "valueType.flows.valueType.screens.valueType.description" ] }, "system/TranslationData": { "sites": [ - "flows.valueType.refusals.valueType.message" + "flows.valueType.refusals.valueType.message", + "flows.valueType.screens.valueType.description" ] }, "system/TranslationItem": { "sites": [ - "flows.valueType.refusals.valueType.message" + "flows.valueType.refusals.valueType.message", + "flows.valueType.screens.valueType.description" ] }, "ui/Action": { diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index c919ac94abf..06af731ed39 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -753,6 +753,7 @@ "docAudienceAllows": "src/system/book.zod.ts#docAudienceAllows (function)", "emailTemplateForm": "src/system/email-template.form.ts#emailTemplateForm (const)", "flowRefusalMessageKey": "src/system/i18n-resolver.ts#flowRefusalMessageKey (function)", + "flowScreenCopyKey": "src/system/i18n-resolver.ts#flowScreenCopyKey (function)", "gcsStorageExample": "src/system/object-storage.zod.ts#gcsStorageExample (const)", "globalFilterKey": "src/system/i18n-resolver.ts#globalFilterKey (function)", "hasMovedFileColumns": "src/system/migration.zod.ts#hasMovedFileColumns (function)", diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 5d1d26394c9..d7f77fcc1b5 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -144,30 +144,30 @@ }, "screens": { "status": "live", - "verifiedAt": "2026-10-02", + "verifiedAt": "2026-10-10", "evidenceScope": "cross-repo", - "evidence": "packages/spec/src/system/i18n-resolver.ts#resolveFlowScreenTitle (reads `flows..screens..title` down the locale chain and falls back to the screen's own title); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#localizeScreen (the heading through `resolveFlowScreenTitle`, and each field's copy from `bundle[language]?.flows?.[flowName]?.screens?.[screen.nodeId]?.fields`); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#overlayFieldCopy (walks the spec's imported `FLOW_SCREEN_FIELD_COPY_KEYS`, which are `label` / `placeholder` / `inlineHelpText`, and overlays each key on its own, falling back to the authored string); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#activeFlowsBundle (reads the active language's `flows` group out of the i18next `translation` resource tree); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#FlowRunner (draws `shown.title` as the dialog title and hands the localized screen to `ScreenView`); objectui @31971ff1e: packages/app-shell/src/views/ScreenView.tsx#ScreenView (draws each field's `label`, hands `placeholder` to the control, and draws `inlineHelpText` under the control, which names it in `aria-describedby`)", + "evidence": "packages/services/service-automation/src/builtin/screen-nodes.ts#registerScreenNodes (the `screen` executor asks for `flows..screens..title` and, where the screen authors body text, `.description`, spelled by `flowScreenCopyKey`, before it renders either template); packages/services/service-automation/src/engine.ts#renderFlowTextSlot (picks the translated template through the `i18n` service in the run's `AutomationContext.locale`, negotiated by `resolveBundleLocale`, and renders it through `renderTextSlot` in place of the authored one; a miss echoes the key and the authored template renders); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#localizeScreen (the heading through `resolveFlowScreenTitle`, and each field's copy from `bundle[language]?.flows?.[flowName]?.screens?.[screen.nodeId]?.fields`); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#overlayFieldCopy (walks the spec's imported `FLOW_SCREEN_FIELD_COPY_KEYS`, which are `label` / `placeholder` / `inlineHelpText`, and overlays each key on its own, falling back to the authored string); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#activeFlowsBundle (reads the active language's `flows` group out of the i18next `translation` resource tree); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#FlowRunner (draws `shown.title` as the dialog title and hands the localized screen to `ScreenView`); objectui @31971ff1e: packages/app-shell/src/views/ScreenView.tsx#ScreenView (draws each field's `label`, hands `placeholder` to the control, and draws `inlineHelpText` under the control, which names it in `aria-describedby`)", "producer": "packages/runtime/src/app-plugin.ts#loadTranslations (hands each bundle's locale data to the i18n service whole, `flows` included); packages/runtime/src/domains/i18n.ts#handleI18nRequest (the translations route answers `getTranslations(locale)` with no group filter); objectui @31971ff1e: apps/console/src/loadLanguage.ts#loadLanguage (fetches the locale's translations and runs `transformSpecTranslations`); objectui @31971ff1e: packages/i18n/src/utils/spec-translations.ts#transformSpecTranslations (forwards every group it does not flatten, `flows` among them, verbatim under the `app` namespace); objectui @31971ff1e: packages/i18n/src/provider.tsx#I18nProvider (adds the loaded payload to the `translation` resource bundle that `activeFlowsBundle` reads)", - "note": "FLIPPED planned → live 2026-09-27 (#20296). It was PLANNED with its container under the #7646 contract-first split: the spec declared the vocabulary and the screen-flow runner half was a downstream objectui card. That half has landed client-side, the side #11287 picked (objectui#5920, FlowRunner's `localizeScreen`), and the `.objectui-sha` pin f8a9d0fb carries it, so every objectui pointer above was read at the pin this repo builds against. Each read is unchanged at objectui main 256b4c9e. Per-screen heading + per-field copy, keyed by `FlowNode.id` / `ScreenFieldConfig.name`, the identifiers the client already holds as `ScreenSpec.nodeId` / `ScreenFieldSpec.name`. Each key falls back to the authored string on its own. The screen `description` and the runner chrome stay untranslated here by ruling. Deeper conventions (`screens..title`, `screens..fields..{label,placeholder,inlineHelpText}`) are governed by the runner and the spec's `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`, not by ledger rows: the one-drill-level boundary this ledger's type note states. MOUNT CHAIN, closed by hand at the pin: `FlowRunner` is mounted by `useConsoleActionRuntime`'s dialogs (the console root `ConsoleShell` renders them, and ObjectView / DeclaredActionsBar each run their own runtime), by `RecordDetailView`, and by the console's `developer/flow-runs` route (`FlowRunsPage`). A `type: 'flow'` action whose run pauses at a screen node opens it with `{ flowName, runId, screen }`. The bundle reaches it through the console root's `I18nProvider loadLanguage` (apps/console main.tsx). ⚠️ NOT flipped with it: the container `flows` keeps `planned` + `authorWarn` for its `label` child, which nothing reads yet (#20318), and its `authorHint` now says that `screens` is read and the flow label is not. That bit is group-level and has two readers: @objectstack/lint's warn map, and the CLI i18n coverage gate (`authorWarnedTranslationGroups` in packages/cli i18n-extract.ts), which holds back the whole `flows.*` demand while it is set. Dropping it before the label has a reader would switch on demand for `flows..label` too, so it drops when #20318 lands (seat ruling on #20296). RE-VERIFIED 2026-10-02 (#17306) at the `.objectui-sha` pin 31971ff1e, which carries objectui 81778b955 (REST compare 81778b955...31971ff1e answers `ahead`, `behind_by 0`): the per-field face gained `inlineHelpText` beside `label` and `placeholder`, the screen field's own help-text key. Every objectui pointer in `evidence` and `producer` was re-read at that pin and repinned from f8a9d0fb. `overlayFieldCopy` imports the spec's key list rather than retyping it, so the new key needed no objectui edit, and `ScreenView` draws the help text under the control (objectui#9248). The status is unchanged: this row was already `live`. 2026-10-05: the `⚠️ NOT flipped with it` clause above is superseded. The `label` child is `live` now (objectui's FlowRunner reads it at the pin 0abd4f9f8), so the container flipped to `live` and dropped `authorWarn` and `authorHint`; see the container note. 2026-10-09 (#22507): the per-field face gained `options.` — a screen field's option labels, keyed by each option's value read as text (`flowScreenFieldOptionKey`) and resolved by `resolveFlowScreenFieldOptions`. It is NOT in `FLOW_SCREEN_FIELD_COPY_KEYS` (a map over `options[]`, not one string), so `overlayFieldCopy` does not reach it, and no objectui code reads it at main 20c6d351a: the option labels are declared ahead of their reader, the downstream runner change. The row stays `live` for the heading and the three string keys, which are unchanged; the one-drill-level boundary keeps the option map off a row of its own." + "note": "FLIPPED planned → live 2026-09-27 (#20296). It was PLANNED with its container under the #7646 contract-first split: the spec declared the vocabulary and the screen-flow runner half was a downstream objectui card. That half has landed client-side, the side #11287 picked (objectui#5920, FlowRunner's `localizeScreen`), and the `.objectui-sha` pin f8a9d0fb carries it, so every objectui pointer above was read at the pin this repo builds against. Each read is unchanged at objectui main 256b4c9e. Per-screen heading + per-field copy, keyed by `FlowNode.id` / `ScreenFieldConfig.name`, the identifiers the client already holds as `ScreenSpec.nodeId` / `ScreenFieldSpec.name`. Each key falls back to the authored string on its own. The screen `description` and the runner chrome stay untranslated here by ruling. Deeper conventions (`screens..title`, `screens..fields..{label,placeholder,inlineHelpText}`) are governed by the runner and the spec's `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`, not by ledger rows: the one-drill-level boundary this ledger's type note states. MOUNT CHAIN, closed by hand at the pin: `FlowRunner` is mounted by `useConsoleActionRuntime`'s dialogs (the console root `ConsoleShell` renders them, and ObjectView / DeclaredActionsBar each run their own runtime), by `RecordDetailView`, and by the console's `developer/flow-runs` route (`FlowRunsPage`). A `type: 'flow'` action whose run pauses at a screen node opens it with `{ flowName, runId, screen }`. The bundle reaches it through the console root's `I18nProvider loadLanguage` (apps/console main.tsx). ⚠️ NOT flipped with it: the container `flows` keeps `planned` + `authorWarn` for its `label` child, which nothing reads yet (#20318), and its `authorHint` now says that `screens` is read and the flow label is not. That bit is group-level and has two readers: @objectstack/lint's warn map, and the CLI i18n coverage gate (`authorWarnedTranslationGroups` in packages/cli i18n-extract.ts), which holds back the whole `flows.*` demand while it is set. Dropping it before the label has a reader would switch on demand for `flows..label` too, so it drops when #20318 lands (seat ruling on #20296). RE-VERIFIED 2026-10-02 (#17306) at the `.objectui-sha` pin 31971ff1e, which carries objectui 81778b955 (REST compare 81778b955...31971ff1e answers `ahead`, `behind_by 0`): the per-field face gained `inlineHelpText` beside `label` and `placeholder`, the screen field's own help-text key. Every objectui pointer in `evidence` and `producer` was re-read at that pin and repinned from f8a9d0fb. `overlayFieldCopy` imports the spec's key list rather than retyping it, so the new key needed no objectui edit, and `ScreenView` draws the help text under the control (objectui#9248). The status is unchanged: this row was already `live`. 2026-10-05: the `⚠️ NOT flipped with it` clause above is superseded. The `label` child is `live` now (objectui's FlowRunner reads it at the pin 0abd4f9f8), so the container flipped to `live` and dropped `authorWarn` and `authorHint`; see the container note. 2026-10-09 (#22507): the per-field face gained `options.` — a screen field's option labels, keyed by each option's value read as text (`flowScreenFieldOptionKey`) and resolved by `resolveFlowScreenFieldOptions`. It is NOT in `FLOW_SCREEN_FIELD_COPY_KEYS` (a map over `options[]`, not one string), so `overlayFieldCopy` does not reach it, and no objectui code reads it at main 20c6d351a: the option labels are declared ahead of their reader, the downstream runner change. The row stays `live` for the heading and the three string keys, which are unchanged; the one-drill-level boundary keeps the option map off a row of its own. 2026-10-10 (#22507, maintainer ruling A): the screen face gained `description`, and the screen's heading and body text moved to the ENGINE. Both are `{{ }}` templates the executor renders per run, so the `screen` executor picks each translated template in the run's locale through the one `i18n` channel the refusal message is read through, before it fills the holes; the served `ScreenSpec` arrives translated, and the authored template renders when the bundle has none. The body text is translated only where the screen authors one. objectui's `localizeScreen` still overlays the heading through `resolveFlowScreenTitle` at the `.objectui-sha` pin 20c6d351a; that overlay is retired by the ruling's consumer half (filed into objectui#12075). Until it lands the runner replaces a served heading with the translated template, which draws a hole literally exactly where it did before this change, so the window adds no regression. The per-field copy stays client-side, read by `overlayFieldCopy` as cited. The row stays `live`: the engine reads both screen keys in-repo, and objectui reads the field keys." }, "refusals": { "status": "live", "verifiedAt": "2026-10-09", "evidenceScope": "in-repo", - "evidence": "packages/services/service-automation/src/engine.ts#translatedRefusalTemplate (the one reader of the group: each `refusals.` entry is read for its `message`, the drilled child below)", + "evidence": "packages/services/service-automation/src/engine.ts#renderRefusalMessage (the one reader of the group: each `refusals.` entry is read for its `message`, the drilled child below)", "children": { "message": { "status": "live", "verifiedAt": "2026-10-09", "evidenceScope": "in-repo", - "evidence": "packages/services/service-automation/src/engine.ts#translatedRefusalTemplate (reads `flows..refusals..message` through the `i18n` service, spelled by `flowRefusalMessageKey`, in the run's `AutomationContext.locale` negotiated by `resolveBundleLocale`; a miss echoes the key and picks nothing); packages/services/service-automation/src/engine.ts#renderRefusalMessage (renders the picked translation in place of the authored `end` `message` through `renderTextSlot`, the one text renderer, so the refusal the run stores and returns is in the run's language; the authored template renders when nothing is picked)", + "evidence": "packages/services/service-automation/src/engine.ts#translatedFlowTemplate (reads `flows..refusals..message` through the `i18n` service, spelled by `flowRefusalMessageKey` and handed over by `renderRefusalMessage`, in the run's `AutomationContext.locale` negotiated by `resolveBundleLocale`; a miss echoes the key and picks nothing); packages/services/service-automation/src/engine.ts#renderRefusalMessage (renders the picked translation in place of the authored `end` `message` through `renderTextSlot`, the one text renderer, so the refusal the run stores and returns is in the run's language; the authored template renders when nothing is picked)", "producer": "packages/runtime/src/app-plugin.ts#loadTranslations (hands each bundle's locale data to the i18n service whole, `flows` included); packages/core/src/fallbacks/authored-translation-sync.ts#wireAuthoredTranslationSync (runtime-authored translation items reach the same service); packages/services/service-automation/src/plugin.ts#i18nServiceReader (the engine's lazy reader of that service); packages/runtime/src/domains/automation.ts#buildAutomationContext and packages/runtime/src/action-execution.ts#dispatchFlowAction (the trigger and action doors forward the request's already-resolved `ExecutionContext.locale` as `AutomationContext.locale`, which persists with a suspended run)" } }, "note": "[#22450] Seeded `live` together with its reader: the key and the engine read that makes it true land in one change (Prime Directive 10). The run stores the refusal already rendered (`sys_automation_run.refusal_message`, `AutomationResult.refusalMessage`), so no overlay on the served string could translate it; the engine picks the translated TEMPLATE before it fills the holes, the validation-message precedent (`objectValidationMessageKey`, read by objectql's rule evaluator through the same `i18n` channel). A resumed leg renders in the locale the run was started in, and a run no person started (record-change, schedule) carries no locale and stores the authored message. The translation is a text slot like the source: `TranslationDataSchema` refuses a single-brace `{token}` in it through `textSlotTemplateRefusal`, and `objectstack validate` (`validate-translation-references.ts`) refuses a key over an unknown flow, an unknown node, or a node that is not an `end` declaring `outcome: 'refused'`. No objectui reader is involved: the console draws the served `refusalMessage` verbatim. DRILLED to its one key, `message`, so no blanket verdict covers an unclassified child: the entry's face is closed (`.strict()`), and `message` carries the reader and producer evidence." } }, - "note": "[#7646] Contract-first spec half of the screen-flow localization split, and `planned` is the honest status rather than `live` or `dead`: `dead` means declared with no consumer and no plan, while this group was ruled into the vocabulary by the maintainer specifically so the runner half could be built against it (the same ruling fixes the boundary — runner chrome, Cancel/Submit, stays in the console's own message catalog, NOT here). Addressing is measured against what the runner already holds: `flows..screens.` — the node id reaches the client verbatim as `ScreenSpec.nodeId` (packages/spec/src/contracts/automation-service.ts:138), which is also what correlates a resume back to its pause point — and `.fields.` (packages/spec/src/automation/builtin-node-config.zod.ts:382, forwarded as `ScreenFieldSpec.name`). Key face measured against `ScreenFieldConfigSchema`, not mirrored from the report: the per-field keys are `label`, `placeholder` and `inlineHelpText`, each spelled as the screen field spells the string it overlays. The report's `help` was never a key here: when #7646 ruled the face the screen field declared nothing help-shaped, so it would have parsed clean and translated nothing (the ADR-0078 shape #6080 kept out of the page-component face). #17306 gave the screen field `inlineHelpText`, and the face gained it on 2026-10-02 once the console drew the help text; `help` / `helpText` / `hint` / `tooltip` / `description` are aliases onto it on the field surface. `options` stayed out, on `guidance`, because a value-keyed map could not address `ScreenFieldConfig.options[].value`, which is unconstrained (superseded 2026-10-09, see the end of this note). Flip to `live` with an objectui screen-flow-runner evidence pointer when the downstream consumer card lands; the resolver-side helper (a `FLOW_SCREEN_COPY_KEYS` sibling of `PAGE_COMPONENT_COPY_KEYS` in packages/spec/src/system/i18n-resolver.ts) is deliberately NOT in this change — #7634 was in flight on that file. 2026-09-27 (#20296): the `screens` child FLIPPED to `live` (objectui's FlowRunner reads it at the `.objectui-sha` pin f8a9d0fb; see that row), and `authorHint` was rewritten to say so. The container keeps `planned` + `authorWarn` for `label` alone, which nothing reads yet (#20318). The CLI i18n coverage gate keys its whole-group `flows.*` demand off this `authorWarn`, so dropping the bit waits for that reader, and then the whole group flips. 2026-10-05: the `label` child FLIPPED to `live` (objectui's FlowRunner names the flow by `flows..label`, then the served `flowLabel`, then the API name, read at the `.objectui-sha` pin 0abd4f9f8; see that row). Both children are `live`, so the container flips with them, and `authorWarn` and `authorHint` are dropped: a `live` row refuses them, and no half of the group is unread any more. Dropping the bit switches the CLI i18n coverage gate's whole-group `flows.*` demand back on, which is what that gate was built to do on this day. 2026-10-09 (#22507): the closing pass for user-read flow text. The face gains the terminal toasts (`successMessage` / `errorMessage`, child rows `planned`) and, inside `screens`, a screen field's option labels (`fields..options.`, each option keyed by `String(value)`; `ScreenFieldConfigSchema` refuses two options of one field that collide as text). Both are read by `translateFlow` and await the objectui runner change, so neither is drawn translated yet. The container stays `live`: its `label` and `screens` readers are unchanged. The screen `description` stays unkeyed and is recorded as owed in `packages/spec/src/system/flows-translation-face.test.ts`: it is a `{{ }}` template the server renders per run, so its reader is a decision of its own." + "note": "[#7646] Contract-first spec half of the screen-flow localization split, and `planned` is the honest status rather than `live` or `dead`: `dead` means declared with no consumer and no plan, while this group was ruled into the vocabulary by the maintainer specifically so the runner half could be built against it (the same ruling fixes the boundary — runner chrome, Cancel/Submit, stays in the console's own message catalog, NOT here). Addressing is measured against what the runner already holds: `flows..screens.` — the node id reaches the client verbatim as `ScreenSpec.nodeId` (packages/spec/src/contracts/automation-service.ts:138), which is also what correlates a resume back to its pause point — and `.fields.` (packages/spec/src/automation/builtin-node-config.zod.ts:382, forwarded as `ScreenFieldSpec.name`). Key face measured against `ScreenFieldConfigSchema`, not mirrored from the report: the per-field keys are `label`, `placeholder` and `inlineHelpText`, each spelled as the screen field spells the string it overlays. The report's `help` was never a key here: when #7646 ruled the face the screen field declared nothing help-shaped, so it would have parsed clean and translated nothing (the ADR-0078 shape #6080 kept out of the page-component face). #17306 gave the screen field `inlineHelpText`, and the face gained it on 2026-10-02 once the console drew the help text; `help` / `helpText` / `hint` / `tooltip` / `description` are aliases onto it on the field surface. `options` stayed out, on `guidance`, because a value-keyed map could not address `ScreenFieldConfig.options[].value`, which is unconstrained (superseded 2026-10-09, see the end of this note). Flip to `live` with an objectui screen-flow-runner evidence pointer when the downstream consumer card lands; the resolver-side helper (a `FLOW_SCREEN_COPY_KEYS` sibling of `PAGE_COMPONENT_COPY_KEYS` in packages/spec/src/system/i18n-resolver.ts) is deliberately NOT in this change — #7634 was in flight on that file. 2026-09-27 (#20296): the `screens` child FLIPPED to `live` (objectui's FlowRunner reads it at the `.objectui-sha` pin f8a9d0fb; see that row), and `authorHint` was rewritten to say so. The container keeps `planned` + `authorWarn` for `label` alone, which nothing reads yet (#20318). The CLI i18n coverage gate keys its whole-group `flows.*` demand off this `authorWarn`, so dropping the bit waits for that reader, and then the whole group flips. 2026-10-05: the `label` child FLIPPED to `live` (objectui's FlowRunner names the flow by `flows..label`, then the served `flowLabel`, then the API name, read at the `.objectui-sha` pin 0abd4f9f8; see that row). Both children are `live`, so the container flips with them, and `authorWarn` and `authorHint` are dropped: a `live` row refuses them, and no half of the group is unread any more. Dropping the bit switches the CLI i18n coverage gate's whole-group `flows.*` demand back on, which is what that gate was built to do on this day. 2026-10-09 (#22507): the closing pass for user-read flow text. The face gains the terminal toasts (`successMessage` / `errorMessage`, child rows `planned`) and, inside `screens`, a screen field's option labels (`fields..options.`, each option keyed by `String(value)`; `ScreenFieldConfigSchema` refuses two options of one field that collide as text). Both are read by `translateFlow` and await the objectui runner change, so neither is drawn translated yet. The container stays `live`: its `label` and `screens` readers are unchanged. The screen `description` stays unkeyed and is recorded as owed in `packages/spec/src/system/flows-translation-face.test.ts`: it is a `{{ }}` template the server renders per run, so its reader is a decision of its own. 2026-10-10 (#22507, maintainer ruling A): that decision is taken. The screen face gains `description`, and the engine picks the translated `title` and `description` templates in the run's locale before it renders them (see the `screens` row). The container stays `live`." }, "metadataForms": { "status": "live", diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 476bcb6fad3..5284e4a9e82 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -108,18 +108,20 @@ export interface AutomationContext { * `action-execution.ts` (REST `POST /api/v1/actions/...` and the MCP * `run_action` bridge). * - * Its reader: the engine's refusing `end` node, which picks the translated - * message at `flows..refusals..message` - * (`flowRefusalMessageKey`) in this locale before it renders the `{{ }}` - * holes (#22450). With no locale, or no translation, the authored message - * renders. + * Its readers pick a translated TEMPLATE in this locale before they render + * the `{{ }}` holes: the engine's refusing `end` node, at + * `flows..refusals..message` (`flowRefusalMessageKey`, + * #22450), and the `screen` executor, at + * `flows..screens..title` / `.description` + * (`flowScreenCopyKey`, #22507). With no locale, or no translation, the + * authored template renders. * * It lives as long as the run: it is persisted with a suspended run, so a * resumed leg renders in the STARTER's locale whoever resumes it (a resume * carries no locale of its own), and a `subflow` / `map` child inherits it * with the rest of the parent's context. Absent on a run no person started: * a record-change, schedule or webhook trigger, or code calling `execute` - * directly. Such a run stores the authored message. + * directly. Such a run renders the authored templates. * * Presentation, not authorization: no security middleware keys on it. */ @@ -300,7 +302,20 @@ export interface ScreenFieldSpec { export interface ScreenSpec { /** The screen node's id (correlates the resume back to this pause point). */ nodeId: string; + /** + * The heading, rendered: `config.title`, or the node label when the screen + * declares none, with its `{{ }}` holes filled — in the run's language. The + * engine picks the translated template (`flowScreenCopyKey`, #22507) in + * {@link AutomationContext.locale} before it renders, so a client draws + * this as served and ⛔ never overlays a translation on it: the holes are + * already filled, and a translated template laid over them would draw them + * as literal text. + */ title?: string; + /** + * The body text, rendered and translated exactly as {@link title} is; absent + * when the screen authors none. + */ description?: string; fields: ScreenFieldSpec[]; /** diff --git a/packages/spec/src/system/flows-translation-face.test.ts b/packages/spec/src/system/flows-translation-face.test.ts index 98425dacd0a..8b6892c5703 100644 --- a/packages/spec/src/system/flows-translation-face.test.ts +++ b/packages/spec/src/system/flows-translation-face.test.ts @@ -6,7 +6,8 @@ * * The family was closed one string at a time — the screen field's help text, * the launcher's flow label, the refused `end` node's message, then a screen - * field's option labels and the terminal toasts — and each time the hole was + * field's option labels and the terminal toasts, then a screen's body text, + * picked by the engine like the refusal — and each time the hole was * found the same way: a fully translated app showed an English string to a * user, and no bundle the author could write reached it. This file moves that * discovery from a locale to the test run. @@ -120,14 +121,12 @@ const RUNNER_SLOTS: Readonly> = { 'node.boundaryConfig': noReader('a boundary event binding — node ids, error codes, timer and signal tokens'), // ScreenConfigSchema (a `screen` node's config) - 'screen.title': keyed('screens.NODE.title', 'the heading above the screen'), - 'screen.description': { - owed: - 'the body text under the heading. The server renders it per run as a `{{ }}` template (`renderTextSlot` in ' - + 'the screen executor), so a translation must be picked BEFORE that render, as the refusal message is — an ' - + 'overlay on the served string would draw a translated hole literally. Which reader picks it (the engine in ' - + "the run's locale, or the runner over a static description only) is the decision this row waits on.", - }, + 'screen.title': keyed('screens.NODE.title', "the heading above the screen, picked by the engine in the run's locale"), + 'screen.description': keyed( + 'screens.NODE.description', + "the body text under the heading, picked by the engine in the run's locale before its `{{ }}` holes are filled " + + '(ruling A on #22507), as the refusal message is', + ), 'screen.fields[].name': noReader('the field name — the address of `fields.`, and the variable the value binds'), 'screen.fields[].label': keyed('screens.NODE.fields.FIELD.label'), 'screen.fields[].type': noReader('a widget token'), @@ -176,7 +175,7 @@ const OTHER_NODE_SLOTS: Readonly> = { }; /** The `owed` rows, pinned EXACTLY: a row leaves only by gaining a key or a route. */ -const OWED = ['screen.description', `${APPROVAL_NODE_TYPE}.decisionOutputs[].label`]; +const OWED = [`${APPROVAL_NODE_TYPE}.decisionOutputs[].label`]; // ── The walk ──────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 6572405c8a0..c0dc75f667c 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -4109,7 +4109,7 @@ describe('translateFlow — screen-flow copy from the `flows` bundle group', () expect(screenOf(out).label).toBe('Conversion Details'); }); - it('ignores off-spec bundle keys the schema refuses — never overlays them (`description`, `help`)', () => { + it('ignores off-spec bundle keys the schema refuses — never overlays them (a field\'s `help`)', () => { // The resolver is deliberately schema-independent (stored rows reach it // via the raw sync path), so the negative is pinned on its own, the same // way `translatePage` pins the retired `submitLabel`. @@ -4119,7 +4119,6 @@ describe('translateFlow — screen-flow copy from the `flows` bundle group', () lead_conversion: { screens: { screen_1: { - description: '不应出现', fields: { opportunityName: { help: '不应出现' } }, }, }, @@ -4129,10 +4128,34 @@ describe('translateFlow — screen-flow copy from the `flows` bundle group', () } as unknown as FlowTestBundle; const out = translateFlow(leadConversion(), offSpec, { locale: 'zh-CN' }); const screen = screenOf(out); - expect(screen.config.description).toBe('Review the details below.'); expect(screen.config.fields.find((f: any) => f.name === 'opportunityName').help).toBeUndefined(); }); + it('overlays a screen\'s body text TEMPLATE where the screen authors one, and never adds one (#22507)', () => { + // The document-level twin of the engine's pick: the translated template + // replaces `config.description` before any render, holes and all. + const withBody: FlowTestBundle = { + 'zh-CN': { + flows: { + lead_conversion: { + screens: { + screen_1: { description: '请核对 {{ record.name }} 的详情。' }, + screen_2: { description: '不应出现' }, + }, + }, + }, + }, + }; + const doc = leadConversion(); + doc.nodes.push({ id: 'screen_2', type: 'screen', label: 'No Body', config: { title: 'No Body' } } as any); + const out = translateFlow(doc, withBody, { locale: 'zh-CN' }); + expect(screenOf(out).config.description).toBe('请核对 {{ record.name }} 的详情。'); + // A screen with no authored body text gets none from the bundle, and + // nothing else on it resolved, so it comes back as the same node. + expect(screenOf(out, 'screen_2').config.description).toBeUndefined(); + expect(screenOf(out, 'screen_2')).toBe(doc.nodes[3]); + }); + it('touches only screen nodes with an id, and does not mutate the input document', () => { const doc = leadConversion(); const frozen = JSON.parse(JSON.stringify(doc)); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index c0682db53c4..67b7f80fb3a 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -2834,6 +2834,26 @@ export function flowRefusalMessageKey(flowName: string, nodeId: string): string return `flows.${flowName}.refusals.${nodeId}.message`; } +/** + * Dot-notation i18n key for a screen's heading or body text — + * `flows..screens..title` / `.description` (#22507). + * + * The sibling of {@link flowRefusalMessageKey}, for the same reason and with + * the same reader. Both slots are `{{ }}` templates the screen executor renders + * per run, so the automation engine picks the translated TEMPLATE in the run's + * locale (`AutomationContext.locale`) through the `i18n` service, which takes a + * key, and only then fills the holes. That is the rule the maintainer's ruling + * on #22507 states once: a user-read flow string the server renders per run is + * translated where it is rendered, in the run's locale, before its holes are + * filled, and a client overlay never touches a server-rendered slot. + * + * `flowName` is `Flow.name`, `nodeId` the screen node's `FlowNode.id` (the + * client's `ScreenSpec.nodeId`), and `key` one of {@link FLOW_SCREEN_COPY_KEYS}. + */ +export function flowScreenCopyKey(flowName: string, nodeId: string, key: FlowScreenCopyKey): string { + return `flows.${flowName}.screens.${nodeId}.${key}`; +} + export function resolveObjectFieldLabels( data: TranslationData | undefined, objectName: string, @@ -4003,10 +4023,10 @@ export interface FlowLike { } /** - * Minimal screen shape consumed by {@link resolveFlowScreenTitle} — satisfied - * by the served `ScreenSpec` (`contracts/automation-service.ts`) a paused run - * hands the client, and equally by a `{ nodeId, title }` pair assembled from a - * screen node's `id` + `config.title` on the server side. + * Minimal screen shape consumed by {@link resolveFlowScreenTitle} — a + * `{ nodeId, title }` pair. Since #22507 a served `ScreenSpec` is NOT one to + * hand it: its `title` arrives already translated by the engine (see + * {@link resolveFlowScreenTitle}). */ export interface FlowScreenLike { /** The screen node's id (`FlowNode.id`) — the `screens` translation key. */ @@ -4030,23 +4050,28 @@ const SCREEN_NODE_TYPE = 'screen'; * omitting one it reads. `translation.zod.ts` declares the same face; * `translation.test.ts` pins the two in agreement. * + * **Both keys are templates the engine picks (#22507).** A screen's heading + * (`config.title`, or the node label when it declares none) and its body text + * (`config.description`) are `{{ }}` templates the screen executor renders per + * run, so the automation engine reads each key ({@link flowScreenCopyKey}) + * through the `i18n` service in the run's locale and renders the translation + * in place of the authored template — the route the refused `end` node's + * message takes ({@link flowRefusalMessageKey}). The served `ScreenSpec` + * therefore arrives translated, and a client overlays neither key on it + * (maintainer ruling A on #22507). {@link translateFlow} applies the same + * keys to a flow DOCUMENT's templates, which is the engine's pick, made on the + * document instead of the run. + * * Deliberately NOT here, measured against the schema face rather than * mirrored from the issue (#7646's report): * - * - `description` — a screen's body text (`config.description`) is - * guidance-refused by the schema, and the family pin - * (`flows-translation-face.test.ts`) records it as owed (#22507): the - * server renders it per run as a `{{ }}` template, so a translation has to - * be picked before that render, which an overlay on the served screen - * cannot do. Growing the face is a schema-side ruled step, never a - * resolver-side accretion. * - runner chrome (Cancel / Submit / the toast's own sentence, `Flow "…" * completed`) — the console's own words in every app, ruled into the * console's message catalog, not the per-app bundle. The flow's AUTHORED * toast text is not chrome: it is keyed at the flow level, * {@link FLOW_TERMINAL_MESSAGE_KEYS}. */ -export const FLOW_SCREEN_COPY_KEYS = ['title'] as const; +export const FLOW_SCREEN_COPY_KEYS = ['title', 'description'] as const; export type FlowScreenCopyKey = typeof FLOW_SCREEN_COPY_KEYS[number]; @@ -4245,14 +4270,19 @@ function lookupFlowScreenFieldCopy( /** * Resolve a translated screen heading against * `flows..screens..title`, falling back to the literal - * `screen.title` (the authored source string the runner would draw anyway), - * and to `undefined` when the screen declares none — the same + * `screen.title`, and to `undefined` when the screen declares none — the same * bundle-then-literal order every resolver on this surface follows. * - * Takes the screen rather than a bare node id so the call site that already - * holds a `ScreenSpec` — the shape a paused `AutomationResult` carries — - * passes it as-is; `nodeId` is the one screen identifier guaranteed stable - * and present client-side (it correlates the resume back to its pause point). + * ⛔ Not for a served `ScreenSpec` since #22507. The engine picks the + * translated heading TEMPLATE in the run's locale before it renders the + * `{{ }}` holes ({@link flowScreenCopyKey}), so a paused run's `ScreenSpec.title` + * is already translated and filled. Overlaying this answer on it would put the + * raw translated template back, holes drawn literally — the boundary the + * client-side route always had. Under maintainer ruling A on #22507 a client + * overlay never touches a server-rendered slot. Its one caller, objectui's + * `FlowRunner` `localizeScreen`, stops overlaying the heading in that ruling's + * consumer half, and this function retires once the `.objectui-sha` pin + * carries that change (the pinned console still imports it). */ export function resolveFlowScreenTitle( bundle: TranslationBundle | undefined, @@ -4272,9 +4302,9 @@ export function resolveFlowScreenTitle( * B, the resolver half #11287): translates the flow's own `label` against * `flows..label`, its terminal toasts against * `flows..{successMessage,errorMessage}` (#22507), and — for every - * `type: 'screen'` node with an id — the screen heading and per-field copy - * against - * `flows..screens..{title,fields..{label,placeholder,inlineHelpText,options.}}`. + * `type: 'screen'` node with an id — the screen heading, body text and + * per-field copy against + * `flows..screens..{title,description,fields..{label,placeholder,inlineHelpText,options.}}`. * The input document is not mutated. * * **The toasts overlay only what the flow authors.** A flow with no @@ -4292,11 +4322,20 @@ export function resolveFlowScreenTitle( * the author relied on — the one-key-covers-both rule the schema's own * `flows` note records. The node's designer-canvas `label` itself is NOT * overlaid: it is not on the declared vocabulary, and rewriting it would - * change the Studio canvas, not the wizard. + * change the Studio canvas, not the wizard. The body text is overlaid only + * where the screen authors a `config.description`, the rule the engine + * applies (#22507): a bundle never adds body text the author did not write. + * + * **A screen's heading and body text are TEMPLATES (#22507).** Both are + * `{{ }}` templates the executor renders per run, and this function overlays + * the translated template on the document, before any render — the same pick + * the engine makes for a run ({@link flowScreenCopyKey}). It never sees a + * served `ScreenSpec`, whose copy the engine has already translated and + * filled. * * **Schema-independent on purpose**, like every translator here: it reads * whatever object it is handed, and overlays ONLY the declared copy keys — - * an off-spec bundle entry carrying a key the schema refuses (`description`, + * an off-spec bundle entry carrying a key the schema refuses (a field's * `help`) is ignored, never overlaid (the negative `translatePage` pins for * the retired `submitLabel`). * @@ -4340,7 +4379,11 @@ export function resolveFlowScreenTitle( * `successMessage` / `errorMessage` to this function as it does the label, * and overlays each served field's options through * {@link resolveFlowScreenFieldOptions} — in a downstream objectui change; - * their ledger rows are `planned` until it lands. + * their ledger rows are `planned` until it lands. A screen's heading and body + * text do NOT take the client side any more (maintainer ruling A on #22507): + * the server renders both per run, so the engine picks their translated + * templates in the run's locale ({@link flowScreenCopyKey}), and the runner + * stops overlaying the served heading. */ export function translateFlow( flow: T, @@ -4408,12 +4451,20 @@ function translateScreenNode( }) : undefined; - if (copy?.title === undefined && !fieldsChanged) return node; + // [#22507] The body text only where the screen authors one — the engine's + // rule: a bundle never adds body text the author did not write. The heading + // lands even with no `config.title` (see `translateFlow`). + const description = typeof cfg?.description === 'string' && cfg.description.length > 0 + ? copy?.description + : undefined; + + if (copy?.title === undefined && description === undefined && !fieldsChanged) return node; return { ...node, config: { ...cfg, ...(copy?.title !== undefined ? { title: copy.title } : {}), + ...(description !== undefined ? { description } : {}), ...(fieldsChanged ? { fields } : {}), }, }; diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 076cc16100d..3af9ee56087 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; -import { PAGE_COMPONENT_COPY_KEYS, FLOW_SCREEN_COPY_KEYS, FLOW_SCREEN_FIELD_COPY_KEYS, FLOW_TERMINAL_MESSAGE_KEYS, flowRefusalMessageKey } from './i18n-resolver'; +import { PAGE_COMPONENT_COPY_KEYS, FLOW_SCREEN_COPY_KEYS, FLOW_SCREEN_FIELD_COPY_KEYS, FLOW_TERMINAL_MESSAGE_KEYS, flowRefusalMessageKey, flowScreenCopyKey } from './i18n-resolver'; import { TEXT_SLOT_TEMPLATE_REFUSAL } from '../automation/flow-text-slot-template'; import { FlowSchema } from '../automation/flow.zod'; import { ScreenConfigSchema, ScreenFieldConfigSchema } from '../automation/builtin-node-config.zod'; @@ -1243,15 +1243,39 @@ describe('translation unknown-key strictness', () => { } }); - it('still refuses a screen `description`, and says the surface carries the option labels now', () => { - // #22507 leaves the body text unkeyed on purpose (the family pin records - // it as owed): it is a server-rendered `{{ }}` template, so the guidance - // says why and lists what the screen face DOES carry. - const issue = parse({ f: { screens: { s: { description: 'x' } } } }) - .error?.issues.find((i) => i.code === 'unrecognized_keys'); - expect(issue).toBeDefined(); - expect(issue?.message).toContain('renders it per run'); - expect(issue?.message).toContain('option labels'); + it('keys a screen\'s `description` — a translated template that keeps the `{{ }}` holes (#22507)', () => { + // Ruling A on #22507: the engine picks this template in the run's locale + // before it fills the holes, so a translation carries the holes of the + // body text it translates, and the address is the one the engine asks for. + const result = parse({ quick_add_task: { screens: { success_screen: { description: '任务“{{ subject }}”已创建。' } } } }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect((result as { data: any }).data.flows.quick_add_task.screens.success_screen.description) + .toBe('任务“{{ subject }}”已创建。'); + expect(flowScreenCopyKey('quick_add_task', 'success_screen', 'description')) + .toBe('flows.quick_add_task.screens.success_screen.description'); + expect(flowScreenCopyKey('quick_add_task', 'success_screen', 'title')) + .toBe('flows.quick_add_task.screens.success_screen.title'); + }); + + it('refuses a single-brace `{token}` in a translated `description`, through the one text-slot judge', () => { + const result = parse({ quick_add_task: { screens: { success_screen: { description: '任务 {subject} 已创建' } } } }); + expect(result.success).toBe(false); + const issue = result.error!.issues[0]!; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['flows', 'quick_add_task', 'screens', 'success_screen', 'description']); + expect(issue.message.slice(0, issue.message.indexOf('. ') + 1)).toBe(TEXT_SLOT_TEMPLATE_REFUSAL); + expect(issue.message).toContain('{{ subject }}'); + // The metadata-item door reads the same shape. + const item = TranslationItemSchema.safeParse({ + locale: 'zh-CN', + flows: { quick_add_task: { screens: { success_screen: { description: '任务 {subject} 已创建' } } } }, + }); + expect(item.success).toBe(false); + expect(item.error!.issues[0]!.path).toEqual(['flows', 'quick_add_task', 'screens', 'success_screen', 'description']); + }); + + it('accepts an empty `description` — the untranslated slot `os i18n extract` writes into a skeleton', () => { + expect(parse({ quick_add_task: { screens: { success_screen: { description: '' } } } }).success).toBe(true); }); it('keeps runner chrome out of the bundle', () => { diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 8d5dd0933ee..dbdbbe84e1a 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -1177,7 +1177,8 @@ const appTranslationDataShape = () => ({ * flows..label * flows..successMessage (the terminal toasts, #22507) * flows..errorMessage - * flows..screens..title + * flows..screens..title (picked by the engine, #22507) + * flows..screens..description (picked by the engine, #22507) * flows..screens..fields..label * flows..screens..fields..placeholder * flows..screens..fields..inlineHelpText @@ -1187,8 +1188,7 @@ const appTranslationDataShape = () => ({ * Every user-read string slot of the flow schemas is either one of these * keys or recorded, with its reason, in the family pin * (`flows-translation-face.test.ts`), so a new slot added without a key - * fails there rather than in a locale. One slot is recorded as owed rather - * than keyed: a screen's `description` (see "Not keyed" below). + * fails there rather than in a locale. * * **The hole this closes (#7646).** A `type: 'screen'` flow is a wizard the * user reads — a heading, a list of labelled inputs — and the bundle had no @@ -1238,14 +1238,16 @@ const appTranslationDataShape = () => ({ * ruling on #7646). * * The runner half was a separate, downstream change, and it has landed - * client-side for both halves of the group. objectui's `FlowRunner` reads - * `screens`: each screen's `title`, and each field's copy over - * `FLOW_SCREEN_FIELD_COPY_KEYS` (`label`, `placeholder`, `inlineHelpText`). - * It reads `label` through `translateFlow` to name the flow in its header - * and in the completion toast, falling back to the label the engine serves - * as `AutomationResult.flowLabel` and then to the flow's API name. Both were + * client-side for each field's copy and the flow label. objectui's + * `FlowRunner` reads each field's copy over `FLOW_SCREEN_FIELD_COPY_KEYS` + * (`label`, `placeholder`, `inlineHelpText`). It reads `label` through + * `translateFlow` to name the flow in its header and in the completion + * toast, falling back to the label the engine serves as + * `AutomationResult.flowLabel` and then to the flow's API name. Both were * measured at the `.objectui-sha` pin `0abd4f9f8`. See the `flows` rows in - * `liveness/translation.json`: the group and its children are `live`. + * `liveness/translation.json`: the group and its children are `live`. A + * screen's `title` and `description` are the engine's to pick, not the + * runner's (see "picked by the engine" below). * * ## `refusals` — a refused `end` node's message (#22450) * @@ -1308,15 +1310,40 @@ const appTranslationDataShape = () => ({ * `screens` was (#7646): objectui's `FlowRunner` reads them in a downstream * change, and their liveness rows are `planned` until it lands. * - * ## Not keyed: a screen's `description` (#22507) + * ## `screens..title` / `.description` — picked by the engine (#22507) + * + * flows..screens..title + * flows..screens..description + * + * A screen's heading and body text are `{{ }}` templates the server renders + * per run (`renderTextSlot` in the screen executor), so an overlay on the + * served string would draw a translated hole as literal text. Maintainer + * ruling A on #22507 states the rule once: a user-read flow string the + * server renders per run is translated where it is rendered, in the run's + * locale, before its holes are filled, and a client overlay never touches a + * server-rendered slot. So the screen executor asks the engine for each + * translated TEMPLATE (`flowScreenCopyKey`, `system/i18n-resolver.ts`) + * through the `i18n` service, in the run's `AutomationContext.locale`, the + * one channel the `refusals` message is read through, and renders it in + * place of the authored template. With no locale, or no entry, the authored + * template renders. The served `ScreenSpec` arrives translated and filled, + * and the runner draws it as served. * - * A screen's body text is a user-read slot with no key yet, and the family - * pin records it as owed, not excluded. It is a `{{ }}` template the server - * renders per run (`renderTextSlot` in the screen executor), so the - * translated TEMPLATE has to be picked before that render, the route the - * `refusals` message takes — an overlay on the served string would draw a - * translated hole as literal text. Which reader picks it is a decision of - * its own, not taken here. + * - **`title`** covers the node label too: the executor draws + * `config.title ?? node.label`, so one key translates whichever heading the + * screen shows. Its address is unchanged from the client-side route it had + * before. + * - **`description`** is translated only where the screen authors one. A + * bundle cannot add body text the author did not write; `objectstack + * validate` refuses such a key. It is judged as the text slot it + * translates: the one text-slot judge (`textSlotTemplateRefusal`) refuses + * a single-brace `{token}` here exactly as `ScreenConfigSchema` refuses it + * on the source. + * - ⚠️ A `title` translation is not judged by that judge yet. The key + * predates the engine pick, and refusing a token in it narrows what the + * face accepts, which is a change of its own. A single-brace token in a + * translated heading renders as literal text, as it did when the console + * overlaid the heading. */ flows: z.record(z.string(), strictObject({ surface: 'this flow translation', @@ -1347,20 +1374,33 @@ const appTranslationDataShape = () => ({ // `dashboards.widgets` and `pages.components` both name, so it gets the // same alias table. aliases: { label: 'title', name: 'title', heading: 'title', header: 'title', inputs: 'fields', items: 'fields' }, - guidance: { - description: - "`description` — a screen's body text (`config.description`) — has no key on the flows translation " - + 'surface yet: the server renders it per run as a `{{ }}` template, so its translation has to be ' - + 'picked before that render, and no reader does that today. This surface carries the heading ' - + '(`title`) and, per field, the label, placeholder, help text and option labels.', - }, }, { - // Overlays the screen node's `config.title`. A screen that declares no - // `title` shows its node `label` instead (`ScreenConfigSchema.title`, - // "falls back to the node label"), so this one key covers whichever of - // the two the runner ends up drawing — the same one-string-one-spelling - // rule `pages..title` follows over `label`. - title: z.string().optional().describe('Translated screen heading (overlays `config.title`, or the node label when the screen declares none)'), + // [#22507] Picked by the ENGINE in the run's locale and rendered in place + // of the screen node's `config.title`. A screen that declares no `title` + // shows its node `label` instead (`ScreenConfigSchema.title`, "falls back + // to the node label"), so this one key covers whichever of the two the + // screen shows — the same one-string-one-spelling rule + // `pages..title` follows over `label`. + title: z.string().optional().describe( + 'Translated screen heading — a `{{ }}` template like the heading it translates, picked by the engine in the ' + + "run's locale and rendered in place of `config.title` (or the node label when the screen declares none)", + ), + // [#22507] The screen's body text, picked the same way. No `.min(1)`, + // like every sibling leaf: an empty string is the untranslated slot + // `os i18n extract` writes into a skeleton, and the engine reads it as no + // translation (the authored body text renders). + description: z.string() + // The one text-slot judge, applied to the translation exactly as + // `ScreenConfigSchema` applies it to the body text it translates. + .superRefine((value, ctx) => { + const refusal = textSlotTemplateRefusal(value); + if (refusal !== undefined) ctx.addIssue({ code: 'custom', message: refusal }); + }) + .optional() + .describe( + 'Translated screen body text — a `{{ }}` template like the `config.description` it translates, picked by ' + + "the engine in the run's locale and rendered in its place; translated only where the screen authors one", + ), fields: z.record(z.string(), strictObject({ surface: 'this flow screen field translation', history: TRANSLATION_HISTORY,