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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/22507-flow-options-toasts-translation.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
Expand All @@ -19,4 +19,4 @@
- **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.<flow>.screens.<node_id>.description`.
12 changes: 12 additions & 0 deletions .changeset/22507-screen-copy-engine-pick-automation.md
Original file line number Diff line number Diff line change
@@ -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.<flow>.screens.<node_id>.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.
10 changes: 10 additions & 0 deletions .changeset/22507-screen-copy-engine-pick-cli.md
Original file line number Diff line number Diff line change
@@ -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.<flow>.screens.<node_id>.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.
9 changes: 9 additions & 0 deletions .changeset/22507-screen-copy-engine-pick-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@objectstack/lint": minor
---

feat(lint): `objectstack validate` judges a translated screen `description`

Clause-②: yes (widening)

- A translation of `flows.<flow>.screens.<node_id>.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.
15 changes: 15 additions & 0 deletions .changeset/22507-screen-copy-engine-pick-spec.md
Original file line number Diff line number Diff line change
@@ -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.<flow>.screens.<node_id>.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.<flow_name>.screens.<node_id>.description`, beside the heading's `flows.<flow_name>.screens.<node_id>.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.
6 changes: 3 additions & 3 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { title?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
| **screens** | `Record<string, { title?: string; description?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |

### Nested Shape: `PlatformTranslationData.metadataForms[string]`
Expand Down Expand Up @@ -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<string, { title?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
| **screens** | `Record<string, { title?: string; description?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |

### Nested Shape: `TranslationData.metadataForms[string]`
Expand Down Expand Up @@ -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<string, { title?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
| **screens** | `Record<string, { title?: string; description?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |

### Nested Shape: `TranslationItem.metadataForms[string]`
Expand Down
31 changes: 18 additions & 13 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export default defineStack({
| Dataset dimension and measure labels | `datasets.<name>.dimensions.<dimension>.label` / `datasets.<name>.measures.<measure>.label` |
| Page labels and `page:header` copy | `pages.<name>.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.<name>.components.<id>.title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.<slot>`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` |
| Screen-flow wizards (flow label, screen headings, screen field copy) | `flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.fields.<field>.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.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.description` / `.fields.<field>.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.<layer>` — 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 |
Expand Down Expand Up @@ -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`
Expand Down
12 changes: 9 additions & 3 deletions examples/app-crm/src/translations/crm.translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '添加商机及其产品明细,二者在同一个事务中一起保存。',
},
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion examples/app-todo/src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export const jaJP: TranslationData = {
},
},
},
success_screen: { title: 'タスクを作成しました' },
success_screen: { title: 'タスクを作成しました', description: 'タスク「{{ subject }}」を作成しました!' },
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion examples/app-todo/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export const zhCN: TranslationData = {
},
},
},
success_screen: { title: '任务已创建' },
success_screen: { title: '任务已创建', description: '任务“{{ subject }}”创建成功!' },
},
},
},
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,9 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
// it, so it keeps its own bucket and reports as `i18n/missing-picklist`.
picklist: 'picklist',
page: 'page',
// Screen-flow copy (`flows.<f>.label`, `flows.<f>.screens.<n>.title`, and
// the per-field `label` / `placeholder` / option labels), the terminal
// Screen-flow copy (`flows.<f>.label`, `flows.<f>.screens.<n>.title` /
// `.description` (#22507), and the per-field `label` / `placeholder` /
// option labels), the terminal
// toasts (`flows.<f>.successMessage` / `.errorMessage`, #22507) and a refused
// `end` node's message (`flows.<f>.refusals.<n>.message`, #22450) — the
// author's own text, so
Expand Down
19 changes: 15 additions & 4 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
* flows.<flow>.label
* flows.<flow>.screens.<node_id>.title (#7646 / #11287)
* flows.<flow>.screens.<node_id>.description (#22507)
* flows.<flow>.screens.<node_id>.fields.<field>.label
* flows.<flow>.screens.<node_id>.fields.<field>.placeholder
* flows.<flow>.screens.<node_id>.fields.<field>.inlineHelpText
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down
Loading
Loading