From d3723c73665b6726702aa13c4be1c488d8e600e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:27:09 +0000 Subject: [PATCH 1/3] fix(spec): a translated flow screen title is judged by the one text-slot judge (#22627) The engine picks a translated screen title in the run's locale and renders it through renderTextSlot, like the description and the refusing end message beside it, but only those two were judged: a single-brace {name} in a translated heading parsed and was drawn as literal text. The three engine-picked template leaves now share one leaf, flowTextSlotTranslation, which runs textSlotTemplateRefusal; an empty string stays accepted. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- packages/spec/src/system/translation.test.ts | 53 +++++++++++++ packages/spec/src/system/translation.zod.ts | 81 ++++++++++++-------- 2 files changed, 102 insertions(+), 32 deletions(-) diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 3af9ee56087..9e38a4115ed 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -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]> = [ + ...FLOW_SCREEN_COPY_KEYS.map((key): [string, unknown, Array] => [ + 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 diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index dbdbbe84e1a..62144f76213 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -52,6 +52,35 @@ const BULK_PARAM_NO_OPTIONS = + 'options, which ARE translatable under `objects..fields..options`), or accept the ' + 'authored option labels.'; +/** + * A translated flow TEXT SLOT — the one leaf every `{{ }}` template the flow + * engine picks from the `flows` group is spelled with: a screen's `title` and + * `description`, and a refusing `end` node's `message`. + * + * The engine picks each of them in the run's locale and renders it through + * `renderTextSlot` (maintainer ruling A on #22507), so a translation is the + * same text slot as the template it translates, and the one text-slot judge + * (`textSlotTemplateRefusal`) refuses here exactly what `ScreenConfigSchema` + * and `EndConfigSchema` refuse on the source: a single-brace `{token}`, which + * would render as literal text, and a hole over a `$` root the engine does not + * bind. One leaf for the three, so a slot cannot be judged in one place and + * drawn literally in the slot beside it (#22627). + * + * No `.min(1)`, like every sibling leaf: an empty string is the untranslated + * slot `os i18n extract` writes into a skeleton, the judge finds no token in + * it, and the engine reads it as no translation (the authored template + * renders). A factory rather than one shared instance, so each slot keeps a + * schema node of its own, as it had before the three were folded into one + * definition. ⛔ Package-internal: the judge's public answer is + * `textSlotTemplateRefusal`, and an exported copy of this leaf would be a + * second one. + */ +const flowTextSlotTranslation = () => + z.string().superRefine((value, ctx) => { + const refusal = textSlotTemplateRefusal(value); + if (refusal !== undefined) ctx.addIssue({ code: 'custom', message: refusal }); + }); + // ──────────────────────────────────────────────────────────────────────────── // Object-level Translation (per-object file) // ──────────────────────────────────────────────────────────────────────────── @@ -1335,15 +1364,15 @@ const appTranslationDataShape = () => ({ * 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. + * validate` refuses such a key. + * - **Both are judged as the text slot they translate** (#22627). The one + * text-slot judge (`textSlotTemplateRefusal`) refuses a single-brace + * `{token}` in either exactly as `ScreenConfigSchema` refuses it on the + * source, through the one leaf the refusing `end` node's `message` uses + * too (`flowTextSlotTranslation`). A translated heading is a template even + * where it stands in for the node label, which is plain text: the engine + * renders whatever it picks through `renderTextSlot`, so a `{name}` there + * would be drawn literally. The refusal names the `{{ name }}` spelling. */ flows: z.record(z.string(), strictObject({ surface: 'this flow translation', @@ -1380,22 +1409,16 @@ const appTranslationDataShape = () => ({ // 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( + // `pages..title` follows over `label`. Judged as the text slot it + // translates, like `description` below (#22627). + title: flowTextSlotTranslation().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 }); - }) + // [#22507] The screen's body text, picked the same way, and judged by the + // one text-slot judge exactly as `ScreenConfigSchema` judges the body + // text it translates; an empty string is the untranslated skeleton slot. + description: flowTextSlotTranslation() .optional() .describe( 'Translated screen body text — a `{{ }}` template like the `config.description` it translates, picked by ' @@ -1437,16 +1460,10 @@ const appTranslationDataShape = () => ({ + 'label. Translate `message`; a `title` belongs to a screen (`screens..title`).', }, }, { - // 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 message renders). - message: z.string() - // The one text-slot judge, applied to the translation exactly as - // `EndConfigSchema` applies it to the message it translates. - .superRefine((value, ctx) => { - const refusal = textSlotTemplateRefusal(value); - if (refusal !== undefined) ctx.addIssue({ code: 'custom', message: refusal }); - }) + // The one text-slot judge, applied to the translation exactly as + // `EndConfigSchema` applies it to the message it translates; an empty + // string is the untranslated skeleton slot (the authored message renders). + message: flowTextSlotTranslation() .optional() .describe( "Translated refusal message of an `end` node declaring `outcome: 'refused'` — a `{{ }}` template like " From c7adcc8ca41341cf888e7629b424c0a5029024b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:29:22 +0000 Subject: [PATCH 2/3] chore(spec): changeset and ADR-0087 D3 entry for the judged screen title translation The narrowing's changeset carries its FROM -> TO, so the ADR-0087 gate refuses a not-required disposition; the D3 semantic entry translation-flow-screen-title-text-slot-refused records it, and the registry is regenerated. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- ...flow-screen-title-translation-text-slot.md | 29 +++++++++++++++ ...ion-flow-screen-title-text-slot-refused.ts | 37 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 33 +++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 .changeset/22627-flow-screen-title-translation-text-slot.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.translation-flow-screen-title-text-slot-refused.ts diff --git a/.changeset/22627-flow-screen-title-translation-text-slot.md b/.changeset/22627-flow-screen-title-translation-text-slot.md new file mode 100644 index 00000000000..b2e4e0d758d --- /dev/null +++ b/.changeset/22627-flow-screen-title-translation-text-slot.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': minor +--- + +A translated flow screen heading (`flows..screens..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) + + + +**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. diff --git a/packages/spec/src/migrations/entries/semantic/18.translation-flow-screen-title-text-slot-refused.ts b/packages/spec/src/migrations/entries/semantic/18.translation-flow-screen-title-text-slot-refused.ts new file mode 100644 index 00000000000..a8344940cc9 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.translation-flow-screen-title-text-slot-refused.ts @@ -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..screens..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.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4d51085210b..26fd88aa026 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -21056,6 +21056,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..screens..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 From cf08a125af227d8707167702481440116f66fe18 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:32:01 +0000 Subject: [PATCH 3/3] chore(spec): record the judged screen title translation in the dropped-refinements ledger The text-slot judge on a translated screen title reaches the runtime and not the published JSON Schema, like its description and refusal-message siblings; the eight schemas that carry the flows face name the site. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .../spec/dropped-refinements.baseline.json | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 5166f3c0236..82e36aafa95 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": 696, + "droppedRefinementSites": 704, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -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", @@ -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": { @@ -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", @@ -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": { @@ -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": {