From fc15e91f51b8710627520fb4f99f0a373376da40 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 13:33:04 +0000 Subject: [PATCH 1/3] feat(spec): give TranslationBundle a `flows` surface for screen-flow wizard copy (#7646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `type: 'screen'` flow is a wizard the user reads — a heading and a list of labelled inputs — and the translation bundle had no group for any of it. Not a drifted key: no key. A translator had nowhere to put a screen title or a screen field label, and the strict shapes refused whatever group they invented, so a fully localized app still rendered its wizard in the source language. Adds `flows` to the shared translation shape, so it lands on both doors (the file-authored bundle and the `translation` metadata item) by construction: flows..label flows..screens..title flows..screens..fields..{label,placeholder} Addressing is measured against what the runner already holds — `Flow.name`, `FlowNode.id` (forwarded verbatim as `ScreenSpec.nodeId`) and `ScreenFieldConfig.name` (forwarded as `ScreenFieldSpec.name`) — rather than invented, so the surface cannot be keyed by names nothing produces. The per-field key face is measured against `ScreenFieldConfigSchema`: `label` and `placeholder` are declared, `help` is not (the screen field has no help-shaped key, so it would validate and never render — the ADR-0078 shape #6080 kept out of the page-component face). `help` and `options` ride the unknown-key guidance instead. Runner chrome (Cancel/Submit) stays in the console's own message catalog, per the maintainer ruling on #7646. Spec half of a contract-first split: the `flows` liveness row is `planned` with an author warning, and flips to `live` when the objectui runner half lands. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HXyTbnqdXNR7cccv5jioAs --- .changeset/translation-flows-surface.md | 54 ++++++ .../docs/references/system/translation.mdx | 2 + ...07-unknown-key-strictness-ledger.counts.md | 2 +- packages/spec/authorable-surface/system.json | 2 + packages/spec/liveness/state-counts.md | 4 +- packages/spec/liveness/translation.json | 19 ++ packages/spec/src/system/translation.test.ts | 177 ++++++++++++++++++ packages/spec/src/system/translation.zod.ts | 151 ++++++++++++++- 8 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 .changeset/translation-flows-surface.md diff --git a/.changeset/translation-flows-surface.md b/.changeset/translation-flows-surface.md new file mode 100644 index 0000000000..2056cedcf1 --- /dev/null +++ b/.changeset/translation-flows-surface.md @@ -0,0 +1,54 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): give TranslationBundle a `flows` surface for screen-flow wizard copy (#7646) + +A `type: 'screen'` flow is a wizard the user reads — a heading, a list of +labelled inputs — and the translation bundle had no group for any of it. Not a +drifted key: **no key**. The bundle's surfaces were objects, apps, dashboards, +pages, settings and metadata forms, so a translator had nowhere to put a screen +title or a screen field label, and the strict shapes (correctly) refused +whatever group they invented. HotCRM finished all four locales and retired its +i18n exemption ledger, and its `lead_conversion` wizard still rendered +"Conversion Details / Create Opportunity? / Opportunity Name" in English on a +zh-CN console. + +**New group — `flows`**, alongside the existing ones on both doors (the +file-authored bundle and the `translation` metadata item, which share one +shape): + +``` +flows.< flow_name >.label +flows.< flow_name >.screens.< node_id >.title +flows.< flow_name >.screens.< node_id >.fields.< field_name >.label +flows.< flow_name >.screens.< node_id >.fields.< field_name >.placeholder +``` + +Minor rather than patch because the accepted authoring surface widens: a bundle +that was previously rejected for carrying `flows` now parses. + +**The addressing is the runner's own, not a second naming scheme.** Each level's +key is an identifier some consumer already holds at render time — the flow's +machine name (`Flow.name`), the screen node's id (`FlowNode.id`, forwarded to +the client verbatim as `ScreenSpec.nodeId`, which is also what correlates a +resume back to its pause point), and the screen field's name +(`ScreenFieldConfig.name`, forwarded as `ScreenFieldSpec.name`). A surface keyed +by names nothing produces would parse clean and translate nothing. + +**The key face is measured against the flow schema, not mirrored from the +report.** `label` and `placeholder` are declared because a screen field declares +them; `help` is not — `ScreenFieldConfigSchema` has nothing help-shaped at all, +so declaring it would be a slot that validates and never renders (the ADR-0078 +shape #6080 kept out of the page-component face). It rides the unknown-key +`guidance` instead, next to `options`, which cannot be addressed by a +value-keyed map because `ScreenFieldConfig.options[].value` is unconstrained. + +**Runner chrome stays out.** The wizard's Cancel/Submit buttons are the +console's own words in every app; they belong to its message catalog, not to a +per-app bundle that would ask every app to re-translate the platform. + +This is the spec half of a contract-first split: the group is declared and +closed, and no shipped screen-flow runner reads it yet. The `flows` row in +`packages/spec/liveness/translation.json` is `planned` and carries an author +warning saying so. diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 617b2732a4..d0c35c7e64 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -146,6 +146,7 @@ Translation data for objects, apps, and UI messages | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record }>` | optional | Dashboard translations keyed by dashboard name | | **pages** | `Record` | optional | Page translations keyed by page name | +| **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | @@ -199,6 +200,7 @@ One locale of translations — the `translation` metadata type | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record }>` | optional | Dashboard translations keyed by dashboard name | | **pages** | `Record` | optional | Page translations keyed by page name | +| **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 766f67aa10..3848b9eee9 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -271,4 +271,4 @@ directory rather than per file. | `kernel/` | 296 | | `qa/` | 6 | | `shared/` | 20 | -| `system/` | 362 | +| `system/` | 365 | diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index d836187808..08550612d5 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1340,6 +1340,7 @@ "system/TranslationCoverageResult:translatedKeys", "system/TranslationData:apps", "system/TranslationData:dashboards", + "system/TranslationData:flows", "system/TranslationData:globalActions", "system/TranslationData:messages", "system/TranslationData:metadataForms", @@ -1363,6 +1364,7 @@ "system/TranslationItem:_provenance", "system/TranslationItem:apps", "system/TranslationItem:dashboards", + "system/TranslationItem:flows", "system/TranslationItem:globalActions", "system/TranslationItem:label", "system/TranslationItem:locale", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index e275c3cb2d..74c1598b9b 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -52,9 +52,9 @@ for both corollaries. | `job` | 15 | 0 | 0 | 0 | 15 | | `mapping` | 14 | 0 | 0 | 0 | 14 | | `seed` | 12 | 0 | 0 | 0 | 12 | -| `translation` | 19 | 0 | 0 | 0 | 19 | +| `translation` | 19 | 0 | 0 | 2 | 21 | | `validation` | 15 | 0 | 3 | 0 | 18 | | `api` | 25 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | -| **total** | **776** | **6** | **52** | **5** | **839** | +| **total** | **776** | **6** | **52** | **7** | **841** | diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 633e0ecfb6..9382413d49 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -60,6 +60,25 @@ "evidence": "packages/spec/src/system/i18n-resolver.ts:636", "note": "translatePage: label/description/title/subtitle (title falls back to label; header copy keyed by page name because page:header instances carry no stable id)." }, + "flows": { + "status": "planned", + "verifiedAt": "2026-08-11", + "authorWarn": true, + "authorHint": "No shipped screen-flow runner reads this group yet — until the objectui half of #7646 lands, a `type: 'screen'` flow renders the strings authored on the flow (`config.title`, `fields[].label`, `fields[].placeholder`) in every locale.", + "children": { + "label": { + "status": "planned", + "verifiedAt": "2026-08-11", + "note": "Overlays `Flow.label`. Same `planned` verdict as its container and for the same reason — declared here, read by no shipped runner yet — but declared per key rather than inherited: the two halves resolve through DIFFERENT consumers when the runner lands (the launcher/wizard header reads the flow label, the screen renderer reads `screens`), so they can go `live` on different days and a blanket verdict would hide the first flip." + }, + "screens": { + "status": "planned", + "verifiedAt": "2026-08-11", + "note": "Per-screen heading + per-field copy, keyed by `FlowNode.id` / `ScreenFieldConfig.name` — the identifiers the client already holds as `ScreenSpec.nodeId` / `ScreenFieldSpec.name`. `planned` with its container: the screen-flow runner half is a downstream objectui card. Deeper conventions (`screens..title`, `screens..fields..{label,placeholder}`) are governed by that runner, not by ledger rows — the one-drill-level boundary this ledger's type note states." + } + }, + "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 — the screen field has no help-shaped key at all, so declaring it would parse clean and translate nothing, the ADR-0078 shape #6080 kept out of the page-component face; 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." + }, "settings": { "status": "live", "verifiedAt": "2026-08-01", diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index f99ed2026f..8e63371fa7 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; import { PAGE_COMPONENT_COPY_KEYS } from './i18n-resolver'; +import { FlowSchema } from '../automation/flow.zod'; +import { ScreenConfigSchema, ScreenFieldConfigSchema } from '../automation/builtin-node-config.zod'; import { TranslationDataSchema, TranslationBundleSchema, @@ -892,6 +894,180 @@ describe('translation unknown-key strictness (#4001)', () => { }); }); + // ────────────────────────────────────────────────────────────────────────── + // #7646 — `flows..screens.`, the screen-flow wizard's copy + // ────────────────────────────────────────────────────────────────────────── + describe('screen-flow copy (#7646)', () => { + const parse = (flows: unknown) => TranslationDataSchema.safeParse({ flows }); + + it('accepts a fully-populated flow entry', () => { + const result = parse({ + lead_conversion: { + label: '转化线索', + screens: { + conversion_details: { + title: '转化详情', + fields: { + create_opportunity: { label: '创建商机?' }, + opportunity_name: { label: '商机名称', placeholder: '输入商机名称' }, + opportunity_amount: { label: '商机金额' }, + }, + }, + }, + }, + }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('accepts partial entries at every level — the family\'s partial-locale semantics', () => { + // Every key on this surface is optional for the same reason + // `ObjectTranslationDataSchema.label` is: partial translation is the + // normal state, and requiring a level to be complete would force + // translators to restate source strings just to pass validation. + expect(parse({ lead_conversion: {} }).success).toBe(true); + expect(parse({ lead_conversion: { label: '转化线索' } }).success).toBe(true); + expect(parse({ lead_conversion: { screens: {} } }).success).toBe(true); + expect(parse({ lead_conversion: { screens: { s1: {} } } }).success).toBe(true); + expect(parse({ lead_conversion: { screens: { s1: { fields: { f: {} } } } } }).success).toBe(true); + // …and one locale of a bundle may carry `flows` while another does not. + expect(TranslationBundleSchema.safeParse({ + en: {}, + 'zh-CN': { flows: { lead_conversion: { screens: { s1: { title: '转化详情' } } } } }, + }).success).toBe(true); + }); + + it('addresses flows/screens/fields by the identifiers the runner resolves against', () => { + // The trap this pins is declared-but-unresolvable: a translation surface + // keyed by names nothing produces parses clean and translates nothing. + // Each level's key is taken from the flow schema itself, and the two + // inner ones reach the client verbatim on the `ScreenSpec` + // (`nodeId` / `fields[].name`, contracts/automation-service.ts). + const flow = FlowSchema.parse({ + name: 'lead_conversion', + label: 'Convert Lead', + type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'conversion_details', + type: 'screen', + label: 'Conversion Details', + config: { + title: 'Conversion Details', + fields: [{ name: 'opportunity_name', label: 'Opportunity Name', placeholder: 'Enter a name' }], + }, + }, + ], + edges: [{ id: 'e1', source: 'start', target: 'conversion_details' }], + }); + const screenNode = flow.nodes.find((n) => n.type === 'screen')!; + const screenConfig = ScreenConfigSchema.parse(screenNode.config); + const fieldName = screenConfig.fields![0]!.name; + + const result = parse({ + [flow.name]: { + label: 'Convert Lead', + screens: { [screenNode.id]: { title: '转化详情', fields: { [fieldName]: { label: '商机名称' } } } }, + }, + }); + expect(result.success).toBe(true); + }); + + it('stays `.strict()` — an invented key is refused at each of the three levels', () => { + for (const body of [ + { lead_conversion: { labell: 'x' } }, + { lead_conversion: { screens: { s1: { titel: 'x' } } } }, + { lead_conversion: { screens: { s1: { fields: { f: { lable: 'x' } } } } } }, + ]) { + const result = parse(body); + expect(result.success, JSON.stringify(body)).toBe(false); + expect(result.error?.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); + } + }); + + it('carries the `label`/`title` trap alias one level down, as its two siblings do', () => { + // A flow's headline is `label`; a screen's is `title`. One level apart, + // opposite spellings — the trap `dashboards.widgets` and + // `pages.components` both name. + expect(parse({ lead_conversion: { screens: { s1: { label: 'x' } } } }) + .error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('`label` → `title`'); + expect(parse({ lead_conversion: { title: 'x' } }) + .error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('`title` → `label`'); + }); + + it('refuses `help` on a screen field rather than declaring a key the field has not got', () => { + // The report proposed label/placeholder/help. `ScreenFieldConfigSchema` + // declares nothing help-shaped, so `help` would parse clean and translate + // nothing (ADR-0078) — and unlike #6080's page-component `help` there is + // no honest key to alias it to, since `placeholder` is the in-input hint + // and not help text. It is `guidance` instead. + const declared = Object.keys((ScreenFieldConfigSchema as unknown as z.ZodObject).shape); + 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('a screen field declares no help/hint copy'); + // …and it must not be re-pointed at `placeholder`, which means something else. + expect(message).not.toContain('`help` → `placeholder`'); + }); + + it('says why select-option labels are not translatable here', () => { + const message = parse({ lead_conversion: { screens: { s1: { fields: { f: { options: {} } } } } } }) + .error?.issues.find((i) => i.code === 'unrecognized_keys')?.message ?? ''; + expect(message).toContain('unconstrained'); + }); + + 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 + // asking each app to re-translate the platform. There is no key for them + // on any of the three levels. + expect(parse({ lead_conversion: { submitLabel: 'Submit', cancelLabel: 'Cancel' } }).success).toBe(false); + expect(parse({ lead_conversion: { screens: { s1: { submitLabel: 'Submit' } } } }).success).toBe(false); + }); + + it('states what the runner applies, and that chrome is not here', () => { + // The describes are the authoring surface's documentation — the JSON + // Schema and the generated reference are built from them. + const json = z.toJSONSchema(TranslationDataSchema as unknown as z.ZodType, { io: 'input' }) as any; + const flowsNode = json.properties.flows; + expect(flowsNode.description).toContain('flow name'); + expect(flowsNode.additionalProperties.properties.screens.description).toContain('node id'); + expect( + flowsNode.additionalProperties.properties.screens.additionalProperties.properties.fields.description, + ).toContain('field name'); + }); + + it('sends a top-level `screens` group down to the flow that owns it', () => { + // A rename would be wrong: screen copy nests under its flow, so the + // content has to move, not be re-spelled — the `app`/`apps` distinction + // the guidance table was built to draw. + const message = TranslationDataSchema.safeParse({ screens: { s1: { title: 'x' } } }) + .error?.issues.find((i) => i.code === 'unrecognized_keys')?.message ?? ''; + expect(message).toContain('flows..screens.'); + expect(message).not.toContain('`screens` → `flows`'); + }); + + it('offers `flow` → `flows` as the rename it actually is', () => { + expect(TranslationDataSchema.safeParse({ flow: { lead_conversion: {} } }) + .error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('`flow` → `flows`'); + }); + + it('reaches the metadata-item door too, not just the file-authored bundle', () => { + // #3778's guard closed one door and left the other open; a group added + // to the shared shape has to land on both by construction. + const flows = { lead_conversion: { screens: { s1: { title: '转化详情' } } } }; + expect(TranslationItemSchema.safeParse({ locale: 'zh-CN', flows }).success).toBe(true); + expect(TranslationItemSchema.safeParse({ locale: 'zh-CN', flows: { f: { titel: 'x' } } }).success).toBe(false); + }); + }); + it('names which action surface the key landed on', () => { const onObject = TranslationDataSchema.safeParse({ objects: { account: { _actions: { merge: { confirm: 'ok?' } } } }, @@ -913,6 +1089,7 @@ describe('translation unknown-key strictness (#4001)', () => { globalActions: { export_csv: { label: 'Export', params: { format: { label: 'Format' } } } }, dashboards: { sales: { label: 'Sales', widgets: { rev: { title: 'Revenue' } } } }, pages: { home: { label: 'Home', title: 'Welcome' } }, + flows: { lead_conversion: { label: 'Convert Lead', screens: { details: { title: 'Details', fields: { name: { label: 'Name', placeholder: 'Enter a name' } } } } } }, settings: { mail: { title: 'Mail', keys: { host: { label: 'Host' } } } }, metadataForms: { object: { label: 'Object', fields: { name: { label: 'Name' } } } }, settingsCommon: { sourceLabels: { env: 'Env', tenant: 'Tenant' } }, diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index e517c20880..f6487b2f60 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -318,7 +318,17 @@ export type LegacyObjectFirstKey = (typeof LEGACY_OBJECT_FIRST_KEYS)[number]; * when the two carry different inner shapes and the content has to be rewritten, * not renamed. */ -const TRANSLATION_KEY_GUIDANCE: Record = { +const TRANSLATION_KEY_GUIDANCE: Record = { + // Not a legacy key either — the top-level spelling of the group #7646 added. + // `screens` → `flows` is distance 4 and would never be suggested, and it is + // not a rename in any case: screen copy nests one level down, under the flow + // that owns the node. An author who writes it at the top level has to move + // the content, not re-spell the key, which is exactly the `app`/`apps` + // distinction this table was built to draw. + screens: + '`screens` is not a top-level translation group — screen copy is addressed under the flow that ' + + "owns it: 'flows..screens..title' and " + + "'flows..screens..fields..label'.", // Not a legacy object-first key — a group that was live-looking and unread // until 17.0.0 (#4667, ADR-0049). The platform's own signature was on it // twice: the schema example showed a concrete override @@ -386,6 +396,27 @@ const TRANSLATION_KEY_GUIDANCE: Record.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 — and on a screen field neither has anything behind it. + * Written as `guidance` rather than aliases because there is no right key to + * send them to: the copy does not exist on this surface at all, and pointing + * at the nearest-looking one would translate the wrong string. + */ +const FLOW_SCREEN_FIELD_NO_HELP = + 'a screen field declares no help/hint copy — `ScreenFieldConfig` is ' + + '`name`/`label`/`type`/`required`/`options`/`defaultValue`/`placeholder`/`visibleWhen`, so there is ' + + 'nothing here to translate. Use `placeholder` for the in-input hint the field does declare.'; + +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 ' + + '`objects..fields..options` uses — cannot address them unambiguously. Author the ' + + "option labels on the node's `config`."; + const translationDataShape = () => ({ /** Object translations */ objects: z.record(z.string(), ObjectTranslationDataSchema).optional().describe('Object translations keyed by object name'), @@ -568,6 +599,120 @@ const translationDataShape = () => ({ })).optional().describe('Per-component copy keyed by component id (`PageComponentSchema.id`)'), })).optional().describe('Page translations keyed by page name'), + /** + * Screen-flow translations keyed by flow name (`Flow.name`). + * + * Convention: + * flows..label + * flows..screens..title + * flows..screens..fields..label + * flows..screens..fields..placeholder + * + * **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 + * group for any of it. Not a drifted key: no key. HotCRM finished all four + * locales, retired its i18n exemption ledger, and its `lead_conversion` + * wizard still rendered "Conversion Details / Create Opportunity? / + * Opportunity Name" in English on a zh-CN console, because a translator had + * nowhere to put the strings and `.strict()` (correctly) refused the group + * they invented. + * + * **The addressing is the runner's own, not a second naming scheme.** Every + * key below is an identifier some consumer already holds at render time: + * + * | level | key | declared by | + * |:---|:---|:---| + * | flow | `Flow.name` | `flow.zod.ts` — the machine name the console launches the run by | + * | screen | `FlowNode.id` | `flow.zod.ts`; forwarded to the client verbatim as `ScreenSpec.nodeId` (`contracts/automation-service.ts`) | + * | field | `ScreenFieldConfig.name` | `builtin-node-config.zod.ts`; forwarded as `ScreenFieldSpec.name` | + * + * `nodeId` is what correlates a resume back to its pause point, so it is the + * one screen identifier that is guaranteed stable and present client-side — + * a positional index would renumber whenever a step is inserted, and node + * `label` is itself display copy. Addressing a translation surface by keys + * nothing produces is the declared-but-unresolvable trap this file exists to + * 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: + * + * - **`help` is not here** — `ScreenFieldConfigSchema` declares + * `name`/`label`/`type`/`required`/`options`/`defaultValue`/`placeholder`/`visibleWhen` + * and nothing help-shaped at all. Declaring it would parse clean and + * translate nothing, the ADR-0078 shape #6080 removed from the page + * component face for exactly this reason. It is `guidance` instead, so an + * author who reaches for it is told the field has no such copy rather than + * sent to a neighbouring key that means something else. + * + * ⛔ **Runner chrome is NOT here** — the Cancel/Submit buttons the wizard + * draws around the author's screen belong to the console's own message + * catalog. They are the console's words in every app, so putting them in a + * per-app bundle would ask every app to re-translate the platform (maintainer + * ruling on #7646). + * + * ⚠️ The runner half is a separate, downstream change: this declares the + * vocabulary and closes it, and no shipped runner reads it yet — see the + * `flows` row in `liveness/translation.json`, which is `planned` and carries + * that warning for authors. + */ + flows: z.record(z.string(), strictObject({ + surface: 'this flow translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', nodes: 'screens', steps: 'screens', screen: 'screens', pages: 'screens' }, + guidance: { + successMessage: + '`flow.successMessage` / `flow.errorMessage` are not part of the flows translation surface — ' + + 'it carries the flow label, per-screen headings and per-screen field copy (#7646). The terminal ' + + 'toast renders the string authored on the flow.', + errorMessage: + '`flow.errorMessage` / `flow.successMessage` are not part of the flows translation surface — ' + + 'it carries the flow label, per-screen headings and per-screen field copy (#7646). The terminal ' + + 'toast renders the string authored on the flow.', + }, + }, { + label: z.string().optional().describe('Translated flow label'), + screens: z.record(z.string(), strictObject({ + surface: 'this flow screen translation', + history: TRANSLATION_HISTORY, + // A screen's headline is `title`; the FLOW's is `label`. Same document + // one level apart with opposite spellings — the trap + // `dashboards.widgets` and `pages.components` both name, so it gets the + // same alias table. + aliases: { label: 'title', name: 'title', heading: 'title', header: 'title', inputs: 'fields', items: 'fields' }, + guidance: { + description: + "`description` — a screen's body text (`config.description`) — is not part of the flows " + + 'translation surface, which carries the heading (`title`) and per-field copy (#7646).', + }, + }, { + // Overlays the screen node's `config.title`. A screen that declares no + // `title` shows its node `label` instead (`ScreenConfigSchema.title`, + // "falls back to the node label"), so this one key covers whichever of + // the two the runner ends up drawing — the same one-string-one-spelling + // rule `pages..title` follows over `label`. + title: z.string().optional().describe('Translated screen heading (overlays `config.title`, or the node label when the screen declares none)'), + fields: z.record(z.string(), strictObject({ + surface: 'this flow screen field translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', text: 'label' }, + 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, + }, + }, { + label: z.string().optional().describe('Translated screen field label'), + placeholder: z.string().optional().describe('Translated screen field placeholder'), + })).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'), + /** * Settings manifest translations keyed by settings namespace * (matches `SettingsManifest.namespace`, e.g. "mail", "branding"). @@ -723,7 +868,7 @@ export const TranslationDataSchema = lazySchema(() => strictObject({ surface: 'this locale of the translation bundle', history: TRANSLATION_HISTORY, guidance: TRANSLATION_KEY_GUIDANCE, - aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', flow: 'flows', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, // `locale` lives on the ITEM, not on a bundle entry (the bundle keys ARE the // locales). Naming it keeps the suggestion useful for an author who moved a // `translation` item into a bundle and left the field behind. @@ -849,7 +994,7 @@ export const TranslationItemSchema = lazySchema(() => strictObject({ surface: 'this translation', history: TRANSLATION_HISTORY, guidance: TRANSLATION_KEY_GUIDANCE, - aliases: { object: 'objects', app: 'apps', page: 'pages', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, + aliases: { object: 'objects', app: 'apps', page: 'pages', flow: 'flows', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, }, { ...translationDataShape(), locale: LocaleSchema.describe('BCP-47 locale this item translates (e.g. "zh-CN")'), From c0ebc183da665ed5d2504d051d2fa08ba3a6564f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 13:36:14 +0000 Subject: [PATCH 2/3] docs(ui): document the `flows` translation surface and its planned boundary (#7646) The bundle's own reference page enumerates every translatable surface and keeps an honest-limits list; adding a group without touching it leaves the page describing a bundle that no longer exists. Adds the `flows` row to "What you can translate" and a boundary bullet saying what is true today: the group is declared and addressed the way the runner resolves it, no shipped runner reads it yet (the liveness row is `planned` and the compile lint warns), a screen field has no help text to translate, and runner chrome lives in the console's message catalog. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HXyTbnqdXNR7cccv5jioAs --- content/docs/ui/translations.mdx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 5f0a9b4fcc..8a068937cb 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -74,6 +74,7 @@ export default defineStack({ | App navigation | `apps..navigation..label` | | Dashboards and widgets | `dashboards.` | | Page labels and `page:header` copy | `pages..label` / `description` / `title` / `subtitle` | +| Screen-flow wizards (flow label, screen headings, screen field copy) | `flows..label` / `flows..screens..title` / `.fields..label` / `.placeholder` — see the boundary note below | | Global actions, settings, messages | `globalActions`, `settings`, `messages` | The metadata types resolved per request are **object, view, action, app, @@ -240,6 +241,15 @@ Honest limits worth knowing before you plan around them: consumer, so a translated rule message was stored and never shown. Author the message on the rule itself (`object.validations[].message`), which the engine returns on every rejected write. +- **The `flows` group is declared, not yet 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. - **No ICU MessageFormat** — plural/gender formatting isn't available; interpolation is always simple `{variable}` substitution. - **Runtime authoring is process-wide.** The authored layer is synced across all From e5c35b8f4ee32ce91536d0953361233fa9e47ba9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 06:35:28 +0000 Subject: [PATCH 3/3] chore(spec): regenerate docs/liveness/strictness-ledger after main merge Wholesale regen (gen:docs, gen:strictness-ledger, gen:liveness-counts) to re-materialize artifacts that drifted from the commits main picked up since this branch was cut. check:generated: 13/13 green. --- content/docs/references/system/translation.mdx | 12 ++++++++++++ ...26-07-unknown-key-strictness-ledger.counts.md | 16 ++++++++-------- packages/spec/liveness/state-counts.md | 4 ++-- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index d0c35c7e64..f70261e709 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -69,6 +69,12 @@ Translation data for a single field --- +## Locale + +BCP-47 Language Tag (e.g. en-US, zh-CN) + +**Type:** `string` + --- @@ -92,6 +98,12 @@ Translation data for a single object --- +## TranslationBundle + +Map of locale codes to translation data + +**Type:** `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>` + --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 3848b9eee9..8f4303cbde 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -22,7 +22,7 @@ regenerate. |---|---| | Triaged directories | 5 | | Object sites in them | 438 | -| Still-open (strip) sites | 181 | +| Still-open (strip) sites | 180 | | Files carrying at least one | 27 | Remaining strip sites by class: @@ -31,7 +31,7 @@ Remaining strip sites by class: |---|---| | authorable — the ruling's forced scope | 40 | | unresolved — needs a per-schema verdict | 34 | -| wire / open — out of forced scope | 105 | +| wire / open — out of forced scope | 104 | | no door — no carrier, ADR-0049 territory | 1 | | no gate — carrier live, no parse | 0 | | covered — no carrier, no parse, guarded at every consumer | 1 | @@ -45,11 +45,11 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| | `ui/` | 161 | 119 | 5 | 0 | 37 | -| `data/` | 165 | 56 | 1 | 0 | 108 | +| `data/` | 165 | 57 | 1 | 0 | 107 | | `automation/` | 65 | 42 | 0 | 0 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **438** | **251** | **6** | **0** | **181** | +| **total** | **438** | **252** | **6** | **0** | **180** | ## File-level triage — site counts @@ -179,7 +179,7 @@ over it is here. ### `data/` — open -**108 strip of 165**, in 16 file(s). +**107 strip of 165**, in 16 file(s). | File | Strip | Sites | |---|---|---| @@ -194,18 +194,18 @@ over it is here. | `external-lookup.zod.ts` | 12 | 12 | | `field-value.zod.ts` | 2 | 3 | | `field.zod.ts` | 2 | 10 | -| `filter.zod.ts` | 11 | 11 | +| `filter.zod.ts` | 10 | 11 | | `hook.zod.ts` | 5 | 7 | | `object.zod.ts` | 1 | 20 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **108** | **165** | +| **total** | **107** | **165** | | Bucket | Sites | |---|---| | authorable — the ruling's forced scope | 8 | | unresolved — needs a per-schema verdict | 34 | -| wire / open — out of forced scope | 66 | +| wire / open — out of forced scope | 65 | | no door — no carrier, ADR-0049 territory | 0 | | no gate — carrier live, no parse | 0 | | covered — no carrier, no parse, guarded at every consumer | 0 | diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 74c1598b9b..9fac7b66cc 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -28,7 +28,7 @@ for both corollaries. | Type | live | exp | dead | planned | classified | |---|---|---|---|---|---| | `object` | 49 | 0 | 0 | 1 | 50 | -| `field` | 66 | 0 | 0 | 0 | 66 | +| `field` | 67 | 0 | 0 | 0 | 67 | | `flow` | 34 | 0 | 6 | 0 | 40 | | `action` | 42 | 0 | 2 | 0 | 44 | | `hook` | 18 | 0 | 2 | 0 | 20 | @@ -57,4 +57,4 @@ for both corollaries. | `api` | 25 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | -| **total** | **776** | **6** | **52** | **7** | **841** | +| **total** | **777** | **6** | **52** | **7** | **842** |