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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/translation-flows-surface.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 2 additions & 0 deletions content/docs/references/system/translation.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,6 +158,7 @@ Translation data for objects, apps, and UI messages
| **globalActions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Global action translations keyed by action name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object> }>` | optional | Dashboard translations keyed by dashboard name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
Expand DownExpand Up@@ -211,6 +212,7 @@ One locale of translations — the `translation` metadata type
| **globalActions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Global action translations keyed by action name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object> }>` | optional | Dashboard translations keyed by dashboard name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
Expand Down
10 changes: 10 additions & 0 deletions content/docs/ui/translations.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,6 +74,7 @@ export default defineStack({
| App navigation | `apps.<app>.navigation.<id>.label` |
| Dashboards and widgets | `dashboards.<name>` |
| Page labels and `page:header` copy | `pages.<name>.label` / `description` / `title` / `subtitle` |
| Screen-flow wizards (flow label, screen headings, screen field copy) | `flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.fields.<field>.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,
Expand DownExpand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -271,4 +271,4 @@ directory rather than per file.
| `kernel/` | 296 |
| `qa/` | 6 |
| `shared/` | 20 |
| `system/` | 362 |
| `system/` | 365 |
2 changes: 2 additions & 0 deletions packages/spec/authorable-surface/system.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand All@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/liveness/state-counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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** | **777** | **6** | **52** | **5** | **840** |
| **total** | **777** | **6** | **52** | **7** | **842** |
19 changes: 19 additions & 0 deletions packages/spec/liveness/translation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<id>.title`, `screens.<id>.fields.<name>.{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.<Flow.name>.screens.<FlowNode.id>` — 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.<ScreenFieldConfig.name>` (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",
Expand Down
Loading
Loading