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
29 changes: 29 additions & 0 deletions .changeset/22627-flow-screen-title-translation-text-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/spec': minor
---

A translated flow screen heading (`flows.<flow_name>.screens.<node_id>.title`) is judged by the same text-slot rule as the body text and the refusal message beside it. A single-brace `{token}` in it is refused when the bundle is parsed, with the `{{ }}` spelling of the token. An empty string is still accepted.

Clause-②: no (narrowing)

<!-- adr-0087: registered translation-flow-screen-title-text-slot-refused -->

**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.

**Why.** The flow engine picks three translated templates in the run's locale and renders each through the one text-slot renderer, the `{{ }}` dialect: a screen's heading (`title`), its body text (`description`) and a refusing `end` node's `message`. The body text and the message refused a single-brace token. The heading accepted it and drew it as literal text, so `title: 'Welcome, {name}'` showed `Welcome, {name}`, and nothing was reported when the bundle was validated, saved or parsed. An earlier entry in this release says a translated screen `title` is not judged by the text-slot rule yet; that describes the face before this change.

**What is refused.** A translated screen heading that the text-slot judge (`textSlotTemplateRefusal`) refuses: a single-brace `{token}`, and a `{{ }}` hole over a `$` name the flow engine does not bind. `TranslationDataSchema` and the `translation` metadata item refuse it at the key's path, with the words the source heading gets from `ScreenConfigSchema`.

**Unchanged.** A heading that keeps the `{{ }}` holes of the text it translates (`'欢迎,{{ record.name }}'`), plain text, and the empty string `os i18n extract` writes into a skeleton, which the engine still reads as no translation. The heading's address does not change, and it still covers the node label a screen with no `config.title` shows.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `title: 'Welcome, {name}'` | `title: 'Welcome, {{ name }}'` where the heading should show the value; `title: 'Welcome'` where the braces were never meant as a hole |

**The one-line fix: write each single-brace token in a translated screen heading as the `{{ }}` hole the refusal names.**

**Who is affected, measured.** In this repository at `86da194919`, four bundles carry a `screens` group (`examples/app-crm`, `examples/app-showcase`, and `examples/app-todo`'s `ja-JP` and `zh-CN`). They hold 8 translated screen headings, and the judge refuses none of them. The same instrument finds the single-brace `{count}` in `examples/app-crm`'s `crm.activity.due_today` message, which is its control. Deployed bundles and other repositories were not measured.

**The ledger.** The D3 semantic entry `translation-flow-screen-title-text-slot-refused` (protocol 18). There is no D2 conversion: a single-brace token in a translated heading has always drawn as literal text, so only the translator can say whether it was meant as a hole.
22 changes: 15 additions & 7 deletions packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"measured": {
"zod": "4.4.3",
"publishedSchemasWithDroppedRefinements": 223,
"droppedRefinementSites": 693,
"droppedRefinementSites": 701,
"refinementSitesThatDidProject": 369,
"refinementSitesWithNoJsonFormToCompare": 0
},
Expand Down Expand Up @@ -104,6 +104,7 @@
"manifest.skills.element.triggerConditions.element",
"manifest.translations.element.valueType.flows.valueType.refusals.valueType.message",
"manifest.translations.element.valueType.flows.valueType.screens.valueType.description",
"manifest.translations.element.valueType.flows.valueType.screens.valueType.title",
"manifest.views.element.form",
"manifest.views.element.form.sections.element.in",
"manifest.views.element.form.submitBehavior.options[1].url",
Expand Down Expand Up @@ -238,7 +239,8 @@
"api/GetTranslationsResponse": {
"sites": [
"translations.flows.valueType.refusals.valueType.message",
"translations.flows.valueType.screens.valueType.description"
"translations.flows.valueType.screens.valueType.description",
"translations.flows.valueType.screens.valueType.title"
]
},
"api/GetUiViewResponse": {
Expand Down Expand Up @@ -316,6 +318,7 @@
"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.translations.element.valueType.flows.valueType.screens.valueType.title",
"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",
Expand Down Expand Up @@ -1166,13 +1169,15 @@
"system/PlatformTranslationBundle": {
"sites": [
"valueType.flows.valueType.refusals.valueType.message",
"valueType.flows.valueType.screens.valueType.description"
"valueType.flows.valueType.screens.valueType.description",
"valueType.flows.valueType.screens.valueType.title"
]
},
"system/PlatformTranslationData": {
"sites": [
"flows.valueType.refusals.valueType.message",
"flows.valueType.screens.valueType.description"
"flows.valueType.screens.valueType.description",
"flows.valueType.screens.valueType.title"
]
},
"system/Schedule": {
Expand Down Expand Up @@ -1218,19 +1223,22 @@
"system/TranslationBundle": {
"sites": [
"valueType.flows.valueType.refusals.valueType.message",
"valueType.flows.valueType.screens.valueType.description"
"valueType.flows.valueType.screens.valueType.description",
"valueType.flows.valueType.screens.valueType.title"
]
},
"system/TranslationData": {
"sites": [
"flows.valueType.refusals.valueType.message",
"flows.valueType.screens.valueType.description"
"flows.valueType.screens.valueType.description",
"flows.valueType.screens.valueType.title"
]
},
"system/TranslationItem": {
"sites": [
"flows.valueType.refusals.valueType.message",
"flows.valueType.screens.valueType.description"
"flows.valueType.screens.valueType.description",
"flows.valueType.screens.valueType.title"
]
},
"ui/Action": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// A translated flow screen heading is judged by the one text-slot judge, like
// the translated body text and refusal message beside it: the engine picks all
// three in the run's locale and renders them as double-brace templates.
// Semantic-only — a single-brace token in a translated heading always drew as
// literal text, so whether it was meant as a hole is the translator's call,
// and no D2 conversion rewrites it.
export const entry: SemanticMigration = {
id: 'translation-flow-screen-title-text-slot-refused',
// No backticks in `surface` — build-upgrade-guide renders it inside a code
// span already, and a nested backtick would close it.
surface:
'translation.flows.<flow>.screens.<node_id>.title — a translated screen heading, in a stack\'s translations '
+ 'or a translation metadata item, carrying a single-brace token such as {name}, or a double-brace hole over a '
+ 'dollar-named variable the flow engine does not bind',
replacement:
'the double-brace hole the refusal names, {{ name }}, where the heading should show the value — the holes of '
+ 'the heading it translates, as the translated description and refusal message already keep them; plain text '
+ 'where the braces were never meant as a hole. An empty string stays the untranslated slot.',
reason:
'The flow engine picks a translated screen heading in the run\'s locale and renders it through the flow '
+ 'text-slot renderer, the double-brace dialect, exactly as it renders the translated body text and a refusing '
+ 'end node\'s message. Those two refused a single-brace token when the bundle was parsed; the heading accepted '
+ 'one and drew it as literal text, so a translator who wrote {name} by analogy with a messages entry shipped a '
+ 'heading that showed the braces. It is now refused at the same parse, with the words the source heading '
+ 'gets. No D2 conversion exists: a single-brace token in a translated heading drew as literal text on every '
+ 'route the heading has taken, the console overlay before the engine pick included, so only the translator '
+ 'can say whether it was meant as a hole.',
acceptanceCriteria:
'Run objectstack validate, which parses the stack\'s translations, and parse each stored translation item: '
+ 'none is refused at a flows screens title. For each heading the refusal named, the translation now carries '
+ 'the holes of the heading it translates, or plain text; run the screen flow in that locale and confirm the '
+ 'heading shows the value, with no stray brace.',
};
33 changes: 33 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21155,6 +21155,39 @@ const step18: MigrationStep = {
+ 'strings covered, and switching the UI locale shows the submit button in that locale — or '
+ 'the author has decided the default copy is acceptable.',
},
// A translated flow screen heading is judged by the one text-slot judge, like
// the translated body text and refusal message beside it: the engine picks all
// three in the run's locale and renders them as double-brace templates.
// Semantic-only — a single-brace token in a translated heading always drew as
// literal text, so whether it was meant as a hole is the translator's call,
// and no D2 conversion rewrites it.
{
id: 'translation-flow-screen-title-text-slot-refused',
// No backticks in `surface` — build-upgrade-guide renders it inside a code
// span already, and a nested backtick would close it.
surface:
'translation.flows.<flow>.screens.<node_id>.title — a translated screen heading, in a stack\'s translations '
+ 'or a translation metadata item, carrying a single-brace token such as {name}, or a double-brace hole over a '
+ 'dollar-named variable the flow engine does not bind',
replacement:
'the double-brace hole the refusal names, {{ name }}, where the heading should show the value — the holes of '
+ 'the heading it translates, as the translated description and refusal message already keep them; plain text '
+ 'where the braces were never meant as a hole. An empty string stays the untranslated slot.',
reason:
'The flow engine picks a translated screen heading in the run\'s locale and renders it through the flow '
+ 'text-slot renderer, the double-brace dialect, exactly as it renders the translated body text and a refusing '
+ 'end node\'s message. Those two refused a single-brace token when the bundle was parsed; the heading accepted '
+ 'one and drew it as literal text, so a translator who wrote {name} by analogy with a messages entry shipped a '
+ 'heading that showed the braces. It is now refused at the same parse, with the words the source heading '
+ 'gets. No D2 conversion exists: a single-brace token in a translated heading drew as literal text on every '
+ 'route the heading has taken, the console overlay before the engine pick included, so only the translator '
+ 'can say whether it was meant as a hole.',
acceptanceCriteria:
'Run objectstack validate, which parses the stack\'s translations, and parse each stored translation item: '
+ 'none is refused at a flows screens title. For each heading the refusal named, the translation now carries '
+ 'the holes of the heading it translates, or plain text; run the screen flow in that locale and confirm the '
+ 'heading shows the value, with no stray brace.',
},
// The judgment half of `translation-per-app-settings-removed`. The D2
// conversion deletes the group mechanically; what it cannot say in a
// `to: '(removed)'` notice is WHERE those strings were rendering and what
Expand Down
53 changes: 53 additions & 0 deletions packages/spec/src/system/translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,59 @@ describe('translation unknown-key strictness', () => {
expect(parse({ quick_add_task: { screens: { success_screen: { description: '' } } } }).success).toBe(true);
});

// #22627 — the heading is the same kind of slot as the body text beside it:
// the engine picks it in the run's locale and renders it through
// `renderTextSlot`, so a `{name}` refused in `description` must not be
// accepted here and drawn literally.
it('refuses a single-brace `{token}` in a translated `title`, through the one text-slot judge (#22627)', () => {
const result = parse({ quick_add_task: { screens: { success_screen: { title: 'Welcome, {name}' } } } });
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', 'title']);
expect(issue.message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(true);
// …and the remedy names the hole spelling of the very token.
expect(issue.message).toContain('{{ name }}');
// The metadata-item door reads the same shape.
const item = TranslationItemSchema.safeParse({
locale: 'zh-CN',
flows: { quick_add_task: { screens: { success_screen: { title: 'Welcome, {name}' } } } },
});
expect(item.success).toBe(false);
expect(item.error!.issues[0]!.path).toEqual(['flows', 'quick_add_task', 'screens', 'success_screen', 'title']);
});

it('accepts a translated `title` that keeps the `{{ }}` holes, and an empty one (the skeleton slot)', () => {
const result = parse({ quick_add_task: { screens: { success_screen: { title: 'Welcome, {{ record.name }}' } } } });
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
expect((result as { data: any }).data.flows.quick_add_task.screens.success_screen.title)
.toBe('Welcome, {{ record.name }}');
expect(parse({ quick_add_task: { screens: { success_screen: { title: '' } } } }).success).toBe(true);
});

it('judges every engine-picked template key alike — one dialect, one judge (#22627)', () => {
// The screen copy keys the engine asks for (`flowScreenCopyKey`) and the
// refusing `end` node's message (`flowRefusalMessageKey`): a key added to
// `FLOW_SCREEN_COPY_KEYS` without the judge fails here.
const faces: Array<[string, unknown, Array<string>]> = [
...FLOW_SCREEN_COPY_KEYS.map((key): [string, unknown, Array<string>] => [
key,
{ f: { screens: { s: { [key]: 'Hi {name}' } } } },
['flows', 'f', 'screens', 's', key],
]),
['message', { f: { refusals: { r: { message: 'Hi {name}' } } } }, ['flows', 'f', 'refusals', 'r', 'message']],
];
// A floor, so an emptied key list cannot pass this vacuously.
expect(faces.map(([key]) => key)).toEqual(expect.arrayContaining(['title', 'description', 'message']));
for (const [key, flows, path] of faces) {
const result = parse(flows);
expect(result.success, `\`${key}\` must be judged`).toBe(false);
const issue = result.error!.issues[0]!;
expect(issue.path, key).toEqual(path);
expect(issue.message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL), key).toBe(true);
}
});

it('keeps runner chrome out of the bundle', () => {
// Cancel/Submit are the CONSOLE's words in every app; the maintainer
// ruling on #7646 keeps them in its own message catalog rather than
Expand Down
Loading
Loading