diff --git a/.changeset/17306-flow-screen-field-help-text-translation.md b/.changeset/17306-flow-screen-field-help-text-translation.md new file mode 100644 index 00000000000..74339a9f4b8 --- /dev/null +++ b/.changeset/17306-flow-screen-field-help-text-translation.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': minor +--- + +A flow screen field's help text is translatable: the `flows` translation face carries `inlineHelpText` beside `label` and `placeholder` (#17306). + +Clause-②: yes (widening) + +- **`TranslationDataSchema`.** `flows..screens..fields.` accepts `inlineHelpText`, the key the screen field itself declares (`ScreenFieldConfig.inlineHelpText`, the object field's spelling). The console's screen dialog draws that text under the control, so a translated help line now renders in the active locale. +- **`FLOW_SCREEN_FIELD_COPY_KEYS`** (`@objectstack/spec/system`) is `['label', 'placeholder', 'inlineHelpText']`. Its readers follow it without an edit: `translateFlow` overlays the key, `os i18n extract` scaffolds it, and objectui's `FlowRunner` overlays it on the field it draws. `FlowScreenFieldLike` gains the optional `inlineHelpText` member. +- **Refusals.** `help`, `helpText`, `hint`, `tooltip` and `description` on a screen field translation are still refused, and the message now names the rename to `inlineHelpText`. They used to be told that the face had no help key. `options` is still refused with its guidance. + +Nothing that parsed before is refused now. A bundle that never wrote a help line is unchanged. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 0cc96ea58bd..921cdb5f9cc 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -489,9 +489,11 @@ only, which is all a bound can do. hidden `visibleWhen` field does not fire — the client is the authority on what was on screen. -Not translatable yet: the flows translation bundle carries `label` and -`placeholder` per field, so `inlineHelpText` renders in the authored language -until that face grows a key for it. +Translatable: a screen field's `inlineHelpText` is translated under +`flows..screens..fields..inlineHelpText`, beside `label` +and `placeholder`, and the console's flow runner overlays it in the active +locale — see the flows row in +[Translations](/docs/ui/translations#what-you-can-translate). **Screen (object form):** diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 452029131b5..9f745da1ed1 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` — see the boundary note below | +| 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 | | 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 | @@ -382,15 +382,16 @@ 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 declared, not yet applied.** A screen flow's copy has +- **The `flows` group is only partly applied.** A screen flow's copy has somewhere to live (#7646) and the keys are addressed the way the runner - resolves them — flow name, screen node id, screen field name — but no shipped - screen-flow runner reads the group yet, so a wizard still renders the strings - authored on the flow. The liveness ledger carries it as `planned` and the - compile lint warns when you author it. Two related limits are deliberate: a - screen field has no help text to translate (it declares none), and the - runner's own chrome — the Cancel and Submit buttons — belongs to the - console's message catalog rather than your app's bundle. + resolves them — flow name, screen node id, screen field name. The console's + screen-flow runner reads `screens`: each screen's `title` and each field's + `label`, `placeholder` and `inlineHelpText` render in the active locale. The + flow's own `label` is read by nothing yet, so the liveness ledger carries the + group as `planned` and the compile lint warns when you author it. The runner's + own chrome — the Cancel and Submit buttons — is deliberately outside the + group: it belongs to the console's message catalog rather than your app's + bundle. **So the tooling does not ask you for these keys either.** `os lint` does not report `flows.*` as missing translations, and `os i18n extract` does not diff --git a/packages/cli/test/i18n-flow-liveness-gate.test.ts b/packages/cli/test/i18n-flow-liveness-gate.test.ts index fcb95b703bd..62b4f465350 100644 --- a/packages/cli/test/i18n-flow-liveness-gate.test.ts +++ b/packages/cli/test/i18n-flow-liveness-gate.test.ts @@ -193,8 +193,10 @@ describe('the liveness gate on the i18n coverage walk', () => { expect(keys).toEqual([ 'flows.lead_conversion.label', + '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', + 'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.inlineHelpText', 'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.label', 'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.placeholder', 'flows.lead_conversion.screens.conversion_details.title', diff --git a/packages/cli/test/i18n-flow-screen-coverage.test.ts b/packages/cli/test/i18n-flow-screen-coverage.test.ts index ca0aec1bb8a..f9a4fa3a630 100644 --- a/packages/cli/test/i18n-flow-screen-coverage.test.ts +++ b/packages/cli/test/i18n-flow-screen-coverage.test.ts @@ -90,7 +90,14 @@ const leadConversion = { description: 'Choose what this lead becomes.', fields: [ { name: 'create_opportunity', label: 'Create Opportunity?', type: 'boolean' }, - { name: 'opportunity_name', label: 'Opportunity Name', placeholder: 'Acme - Q3 renewal' }, + { + name: 'opportunity_name', + label: 'Opportunity Name', + placeholder: 'Acme - Q3 renewal', + // Every per-field copy key is authored on this one field, so the + // key-face pin below compares the whole spec list, not a subset. + inlineHelpText: 'Shown on the quote', + }, ], }, }, @@ -137,6 +144,7 @@ describe('the screen-flow gap a green i18n gate could not see (#11485)', () => { 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'); + expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.opportunity_name.inlineHelpText'); // The object surface IS translated, so nothing else is reported: the whole // report is the wizard. Before this bucket the same tree reported zero. expect(zh.length).toBeGreaterThan(0); @@ -166,7 +174,7 @@ describe('the screen-flow gap a green i18n gate could not see (#11485)', () => { title: '转化详情', fields: { create_opportunity: { label: '创建商机?' }, - opportunity_name: { label: '商机名称', placeholder: 'Acme - 第三季度续约' }, + opportunity_name: { label: '商机名称', placeholder: 'Acme - 第三季度续约', inlineHelpText: '显示在报价单上' }, }, }, summary: { title: '完成' }, @@ -192,8 +200,10 @@ 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.fields.create_opportunity.inlineHelpText', 'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.label', 'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.placeholder', + 'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.inlineHelpText', 'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.label', 'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.placeholder', 'flows.lead_conversion.screens.conversion_details.title', @@ -276,6 +286,7 @@ describe('`os i18n extract` scaffolds the flows skeleton', () => { expect(en.flows.lead_conversion.screens.conversion_details.fields.opportunity_name).toEqual({ label: 'Opportunity Name', placeholder: 'Acme - Q3 renewal', + inlineHelpText: 'Shown on the quote', }); // The translator's empty slots — the vocabulary an author had no way to // discover before this pass existed. @@ -424,13 +435,16 @@ describe('a screen inside an ADR-0031 region (#17511)', () => { // The exact face, so a key that should NOT exist fails here too. Eight of // these ten were absent before the descent landed; `flows.onboarding.label` // and `screens.welcome.title` are the two the flat walk already reached. + // The two `inlineHelpText` rows joined with the per-field face (#17306). expect(flowKeys({ flows: [nestedOnboarding] }).sort()).toEqual([ 'flows.onboarding.label', 'flows.onboarding.screens.accept_terms.title', 'flows.onboarding.screens.card_details.title', 'flows.onboarding.screens.payment_failed.title', + 'flows.onboarding.screens.pick_region.fields.notes.inlineHelpText', 'flows.onboarding.screens.pick_region.fields.notes.label', 'flows.onboarding.screens.pick_region.fields.notes.placeholder', + 'flows.onboarding.screens.pick_region.fields.region_code.inlineHelpText', 'flows.onboarding.screens.pick_region.fields.region_code.label', 'flows.onboarding.screens.pick_region.fields.region_code.placeholder', 'flows.onboarding.screens.pick_region.title', diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 95d5c06709e..5d186ac954a 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -116,7 +116,7 @@ "status": "planned", "verifiedAt": "2026-08-11", "authorWarn": true, - "authorHint": "Only part of this group is read. The console's screen-flow runner reads `screens`: each screen's `title` and each field's `label` / `placeholder` render in the active locale. The flow's own `label` is read by nothing yet, so a translated flow label is stored and never shown, and the flow keeps the label authored on it in every locale.", + "authorHint": "Only part of this group is read. The console's screen-flow runner reads `screens`: each screen's `title` and each field's `label` / `placeholder` / `inlineHelpText` render in the active locale. The flow's own `label` is read by nothing yet, so a translated flow label is stored and never shown, and the flow keeps the label authored on it in every locale.", "children": { "label": { "status": "planned", @@ -125,14 +125,14 @@ }, "screens": { "status": "live", - "verifiedAt": "2026-09-27", + "verifiedAt": "2026-10-02", "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 @f8a9d0fb: packages/app-shell/src/views/FlowRunner.tsx#localizeScreen (the heading through `resolveFlowScreenTitle`, and each field's `label` / `placeholder` from `bundle[language]?.flows?.[flowName]?.screens?.[screen.nodeId]?.fields` over the spec's `FLOW_SCREEN_FIELD_COPY_KEYS`); objectui @f8a9d0fb: packages/app-shell/src/views/FlowRunner.tsx#activeFlowsBundle (reads the active language's `flows` group out of the i18next `translation` resource tree); objectui @f8a9d0fb: packages/app-shell/src/views/FlowRunner.tsx#FlowRunner (draws `shown.title` as the dialog title and hands the localized screen to `ScreenView`)", - "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 @f8a9d0fb: apps/console/src/loadLanguage.ts#loadLanguage (fetches the locale's translations and runs `transformSpecTranslations`); objectui @f8a9d0fb: packages/i18n/src/utils/spec-translations.ts#transformSpecTranslations (forwards every group it does not flatten, `flows` among them, verbatim under the `app` namespace); objectui @f8a9d0fb: 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}`) 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)." + "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`)", + "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`." } }, - "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: `label` + `placeholder` are declared, `help` is NOT — but ⚠️ no longer for its original reason: #17306 gave the screen field `ScreenFieldConfig.inlineHelpText`, so the help copy is REAL and what is missing is only THIS face's translation key for it. Growing that face is a ruled step against the #7646 enumeration, not a resolver-side accretion, so until it lands a `help` entry here would still parse clean and translate nothing — the ADR-0078 shape #6080 kept out of the page-component face, on a not-yet reason rather than an absent-key one; it rides `guidance` on the field surface instead, alongside `options`, which cannot be addressed by a value-keyed map because `ScreenFieldConfig.options[].value` is unconstrained. 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." + "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` stays out, on `guidance`, because a value-keyed map cannot address `ScreenFieldConfig.options[].value`, which is unconstrained. 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." }, "metadataForms": { "status": "live", diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index e308a473c94..4ac9e2347c8 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -3645,6 +3645,38 @@ describe('translateFlow (#11287)', () => { expect(screen.config.description).toBe('Review the details below.'); }); + it('translates a field\'s help text (`inlineHelpText`) key by key, beside its label and placeholder', () => { + // #17306: the screen dialog draws `inlineHelpText` under the control, and + // the per-field face carries it under the same key. A field whose bundle + // entry omits it keeps the authored help line; one with no help authored + // gets none invented from the bundle's neighbours. + const doc = leadConversion(); + const fields = screenOf(doc).config.fields; + fields.find((f: any) => f.name === 'opportunityName').inlineHelpText = 'Shown on the quote'; + fields.find((f: any) => f.name === 'opportunityAmount').inlineHelpText = 'Between 1 and 10'; + const withHelp: FlowTestBundle = { + 'zh-CN': { + flows: { + lead_conversion: { + screens: { + screen_1: { + fields: { + opportunityName: { label: '商机名称', inlineHelpText: '显示在报价单上' }, + opportunityAmount: { label: '商机金额' }, + }, + }, + }, + }, + }, + }, + }; + const out = translateFlow(doc, withHelp, { locale: 'zh-CN' }); + const byName = (n: string) => screenOf(out).config.fields.find((f: any) => f.name === n); + expect(byName('opportunityName')).toMatchObject({ label: '商机名称', inlineHelpText: '显示在报价单上' }); + expect(byName('opportunityAmount')).toMatchObject({ label: '商机金额', inlineHelpText: 'Between 1 and 10' }); + expect(byName('createOpportunity').inlineHelpText).toBeUndefined(); + }); + it('negative control — a flow the bundle does not carry comes back unchanged (same reference)', () => { const doc = { ...leadConversion(), name: 'other_flow' }; expect(translateFlow(doc, bundle, { locale: 'zh-CN' })).toBe(doc); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 76359a6a9f3..c9cc9d34edd 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -3740,7 +3740,7 @@ export function resolveMetadataFormSchemaTitles>( * `ScreenFieldConfigSchema` (`automation/builtin-node-config.zod.ts`) narrowed * to what the overlay reads and writes. The served `ScreenFieldSpec` * (`contracts/automation-service.ts`) satisfies it structurally too: the - * executor forwards `name` / `label` / `placeholder` verbatim, so the same + * executor forwards `name` / `label` / `placeholder` / `inlineHelpText` verbatim, so the same * overlay works whichever side of the wire the runner half lands on. */ export interface FlowScreenFieldLike { @@ -3752,6 +3752,8 @@ export interface FlowScreenFieldLike { name?: string; label?: string; placeholder?: string; + /** Help text drawn under the input (`ScreenFieldConfig.inlineHelpText`). */ + inlineHelpText?: string; [key: string]: unknown; } @@ -3825,19 +3827,24 @@ export type FlowScreenCopyKey = typeof FLOW_SCREEN_COPY_KEYS[number]; * the per-FIELD face of {@link FLOW_SCREEN_COPY_KEYS}, measured against * `ScreenFieldConfigSchema`. * - * `help` is deliberately absent. ⚠️ Not for its original reason any more: the - * screen field used to declare nothing help-shaped, so a `help` key would have - * parsed clean and translated nothing (the ADR-0078 shape #6080 kept out of the - * page-component face). #17306 gave it `inlineHelpText`, so the string exists — - * what does not exist is a key on THIS face for it, and growing the face is a - * ruled step against the #7646 enumeration, never a resolver-side accretion. - * The exclusion therefore stands with the same outcome and a different reason. + * Each key is spelled exactly as the screen field spells the string it + * overlays, because the overlay writes the translation back onto that same + * key: {@link translateScreenField} spreads the resolved copy over the field, + * and objectui's `FlowRunner` walks this list over the `ScreenFieldSpec` it + * draws. + * + * `inlineHelpText` joined `label` and `placeholder` once the console's screen + * dialog drew the help text under the control (#17306), so a translated help + * line reaches the user rather than a bundle nothing reads. It is the screen + * field's own spelling, which is the object field's (`FieldSchema`), so the + * report's `help` is not a key here: the schema answers `help` / `helpText` / + * `hint` / `tooltip` by name with the rename to `inlineHelpText`. * * `options` is absent because `ScreenFieldConfig.options[].value` is - * unconstrained, so a value-keyed map cannot address the labels. Both are - * refused by name with guidance at the schema. + * unconstrained, so a value-keyed map cannot address the labels. It is refused + * by name with guidance at the schema. */ -export const FLOW_SCREEN_FIELD_COPY_KEYS = ['label', 'placeholder'] as const; +export const FLOW_SCREEN_FIELD_COPY_KEYS = ['label', 'placeholder', 'inlineHelpText'] as const; export type FlowScreenFieldCopyKey = typeof FLOW_SCREEN_FIELD_COPY_KEYS[number]; @@ -3935,7 +3942,7 @@ export function resolveFlowScreenTitle( * B, the resolver half #11287): translates the flow's own `label` against * `flows..label`, and — for every `type: 'screen'` node with an id — * the screen heading and per-field copy against - * `flows..screens..{title,fields..{label,placeholder}}`. + * `flows..screens..{title,fields..{label,placeholder,inlineHelpText}}`. * The input document is not mutated. * * **Where the translated title lands.** The bundle's `title` is written to diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 2494313683d..b23b40b10d6 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1179,32 +1179,32 @@ describe('translation unknown-key strictness (#4001)', () => { .toContain('`title` → `label`'); }); - it('refuses `help` on a screen field, and says the string exists but the key does not', () => { - // The report proposed label/placeholder/help. `help` is still refused — - // but ⚠️ its reason changed with #17306 and this pin changed with it. - // The old reason was that the field declared nothing help-shaped; it now - // declares `inlineHelpText` (the object field's spelling), so the copy is - // real and only THIS face's key for it is missing. The refusal must not - // keep telling an author the field has no help copy when it has. + it('translates a screen field\'s help text under the key the field itself declares', () => { + // The report proposed label/placeholder/help. The face carries the help + // line as `inlineHelpText` — the screen field's own key (the object + // field's spelling), declared on `ScreenFieldConfigSchema` by #17306 — + // because the overlay writes each translation back onto the key it names. const declared = Object.keys((ScreenFieldConfigSchema as unknown as z.ZodObject).shape); - expect(declared).toContain('inlineHelpText'); - // The bare spellings stay undeclared on the schema — `inlineHelpText` is - // the one landing key, so the translation face has exactly one candidate. - expect(declared).not.toContain('help'); - expect(declared).not.toContain('helpText'); - expect(declared).toContain('label'); - expect(declared).toContain('placeholder'); - - const message = parse({ lead_conversion: { screens: { s1: { fields: { f: { help: 'x' } } } } } }) - .error?.issues.find((i) => i.code === 'unrecognized_keys')?.message ?? ''; - expect(message).toContain('would translate nothing'); - expect(message).toContain('inlineHelpText'); - // …and it must not be re-pointed at `placeholder`, which means something else. - expect(message).not.toContain('`help` → `placeholder`'); - // The card that moved this reason is named in the code comment above the - // string, never IN the string: this text is printed AT the author, who - // has no tracker, so `#NNNN` resolves to nothing (check:doc-authoring). - expect(message).not.toMatch(/#\d{3,5}\b/); + for (const key of FLOW_SCREEN_FIELD_COPY_KEYS) expect(declared).toContain(key); + expect(FLOW_SCREEN_FIELD_COPY_KEYS).toContain('inlineHelpText'); + + const result = parse({ lead_conversion: { screens: { s1: { fields: { f: { inlineHelpText: '介于 1 到 10 之间' } } } } } }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('refuses the neighbouring help spellings by name, with the rename to `inlineHelpText`', () => { + // `help` is right on an object FIELD translation, `helpText` on an action + // param; here each is refused (`.strict()`) and the message names the key + // that would have been read. `description` rides the same rename: an object + // field uses it for tooltip copy, and a screen field has no description. + for (const spelling of ['help', 'helpText', 'hint', 'tooltip', 'description']) { + const issue = parse({ lead_conversion: { screens: { s1: { fields: { f: { [spelling]: 'x' } } } } } }) + .error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue, spelling).toBeDefined(); + expect(issue?.message).toContain(`\`${spelling}\` → \`inlineHelpText\``); + // …never re-pointed at `placeholder`, the in-input hint, a different string. + expect(issue?.message).not.toContain('→ `placeholder`'); + } }); it('says why select-option labels are not translatable here', () => { diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 59c1a812be0..4d608f3da35 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -697,32 +697,6 @@ const ITEM_TRANSLATION_KEY_GUIDANCE: Record = { * building the shape eagerly at module load would defeat the laziness those * exist for. */ -/** - * The two measured exclusions on `flows..screens..fields.`. - * - * Both are keys an author reaches for from a neighbouring surface — `help` is - * correct on an object FIELD translation and on a settings key, `options` on - * both of those too. - * - * ⚠️ The REASON for the help exclusion changed with #17306 and the message - * below changed with it. It used to be that `ScreenFieldConfig` declared - * nothing help-shaped, so a `help` key here would have translated a string that - * did not exist. The schema now declares `inlineHelpText`, so the string is - * real; what is still absent is this TRANSLATION face's key for it, which is a - * ruled widening of its own (the #7646 enumeration) and not something a - * resolver-side accretion may grow. Until that lands, a `help` entry here would - * still translate nothing — the same refusal, on an honest reason. - * - * Written as `guidance` rather than an alias because there is still no right - * key on THIS surface to send them to: pointing `help` at `placeholder` would - * translate the in-input hint, a different string that means something else. - */ -const FLOW_SCREEN_FIELD_NO_HELP = - 'the flows translation face carries `label` and `placeholder` only, so a help entry here would ' - + 'translate nothing. The screen field itself does declare help copy ' - + '(`ScreenFieldConfig.inlineHelpText`); what is missing is a translation key for it, not the ' - + 'string. ⛔ Do not use `placeholder` instead — that is the in-input hint, a different string.'; - /** * The measured exclusion on `datasets..dimensions.` and * `.measures.`. @@ -740,6 +714,19 @@ const DATASET_MEMBER_NO_DESCRIPTION = + '`description` is declared on the DATASET itself: translate it at ' + "'datasets..description'."; +/** + * The measured exclusion on `flows..screens..fields.`. + * + * `options` is a key an author reaches for from a neighbouring surface — an + * object FIELD translation and a settings key both carry it. There is no right + * key on THIS surface to send them to, which is why this is `guidance` and not + * an alias. + * + * The face's other neighbouring-surface miss, the help spellings, is no longer + * an exclusion: the face carries `inlineHelpText`, the screen field's own key + * for its help line, so `help` / `helpText` / `hint` / `tooltip` / + * `description` are aliases onto it (#17306). + */ const FLOW_SCREEN_FIELD_NO_OPTIONS = 'select-option labels are not translatable on a screen field: `ScreenFieldConfig.options[].value` is ' + 'unconstrained (numbers and booleans are legal), so an option map keyed by value — the shape ' @@ -1199,6 +1186,7 @@ const appTranslationDataShape = () => ({ * flows..screens..title * flows..screens..fields..label * flows..screens..fields..placeholder + * flows..screens..fields..inlineHelpText * * **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 @@ -1226,20 +1214,20 @@ const appTranslationDataShape = () => ({ * keep out. * * **The key face is measured against the flow schema, not mirrored from the - * report.** Two of the three per-field keys the issue proposed are real - * (`label`, `placeholder`); `help` is not: + * report.** The per-field keys are the screen field's own copy keys, spelled + * as `ScreenFieldConfigSchema` spells them, because the overlay writes each + * translation back onto the key it names: `label`, `placeholder` and + * `inlineHelpText`. * - * - **`help` is not here.** Originally because `ScreenFieldConfigSchema` - * declared nothing help-shaped at all, so the key would have parsed clean - * and translated nothing — the ADR-0078 shape #6080 removed from the page - * component face for exactly this reason. ⚠️ That premise expired with - * #17306, which gave the screen field `inlineHelpText` (the object field's - * own spelling). The copy now exists; this face's key for it does not, and - * growing the face is a ruled step against the #7646 enumeration rather - * than a resolver-side accretion. So the exclusion stands and the outcome - * is unchanged — a `help` entry still translates nothing — but it is now a - * NOT-YET, and the guidance says so rather than telling an author the field - * has no help copy when it has. + * - **Help text is `inlineHelpText`, not the report's `help`.** When #7646 + * ruled the face, the screen field declared nothing help-shaped, so a + * `help` key would have parsed clean and translated nothing — the ADR-0078 + * shape #6080 removed from the page component face for exactly this + * reason. #17306 gave the screen field `inlineHelpText` (the object + * field's own spelling), and the face gained the key once the console's + * screen dialog drew it, under the control. `help` / `helpText` / `hint` / + * `tooltip` / `description` are aliases onto it, so an author holding a + * neighbouring surface's spelling is told the rename. * * ⛔ **Runner chrome is NOT here** — the Cancel/Submit buttons the wizard * draws around the author's screen belong to the console's own message @@ -1249,8 +1237,9 @@ const appTranslationDataShape = () => ({ * * The runner half was a separate, downstream change, and it has landed * client-side for the per-screen copy. objectui's `FlowRunner` reads - * `screens` (each screen's `title`, and each field's `label` and - * `placeholder`), measured at the `.objectui-sha` pin `f8a9d0fb`. The flow's own + * `screens`: each screen's `title`, and each field's copy over + * `FLOW_SCREEN_FIELD_COPY_KEYS` (`label`, `placeholder`, `inlineHelpText`), + * measured at the `.objectui-sha` pin `31971ff1e`. The flow's own * `label` is read by nothing yet. See the `flows` rows in * `liveness/translation.json`: `screens` is `live`, `label` stays `planned`, * and the group's author warning names the unread half. @@ -1294,13 +1283,15 @@ const appTranslationDataShape = () => ({ fields: z.record(z.string(), strictObject({ surface: 'this flow screen field translation', history: TRANSLATION_HISTORY, - aliases: { name: 'label', title: 'label', text: 'label' }, + // The help spellings are the ones `ScreenFieldConfigSchema` renames + // onto `inlineHelpText` (the object field's table), plus + // `description`, which an object field uses for its tooltip copy. + aliases: { + name: 'label', title: 'label', text: 'label', + help: 'inlineHelpText', helpText: 'inlineHelpText', hint: 'inlineHelpText', tooltip: 'inlineHelpText', + description: 'inlineHelpText', + }, guidance: { - help: FLOW_SCREEN_FIELD_NO_HELP, - helpText: FLOW_SCREEN_FIELD_NO_HELP, - hint: FLOW_SCREEN_FIELD_NO_HELP, - tooltip: FLOW_SCREEN_FIELD_NO_HELP, - description: FLOW_SCREEN_FIELD_NO_HELP, options: FLOW_SCREEN_FIELD_NO_OPTIONS, choices: FLOW_SCREEN_FIELD_NO_OPTIONS, values: FLOW_SCREEN_FIELD_NO_OPTIONS, @@ -1308,6 +1299,7 @@ const appTranslationDataShape = () => ({ }, { label: z.string().optional().describe('Translated screen field label'), placeholder: z.string().optional().describe('Translated screen field placeholder'), + inlineHelpText: z.string().optional().describe('Translated screen field help text (drawn under the input)'), })).optional().describe('Screen field translations keyed by field name (`ScreenFieldConfig.name`)'), })).optional().describe('Screen translations keyed by screen node id (`FlowNode.id`, the client\'s `ScreenSpec.nodeId`)'), })).optional().describe('Screen-flow translations keyed by flow name'),