Repository navigation
Commit d25b187
feat(i18n): the engine translates a screen's title and description in the run's locale (#22626)
Fixes #22507
Clause-②: yes (widening)
This PR implements the maintainer's ruling A on #22507 (comment
6093472708, 「22507 同意」): the engine picks a screen's translated `title`
and `description` templates in the run's locale, then renders them. The
ruling states the rule once:
> a user-read flow string the server renders per run is translated where
it is rendered, in the run's locale, before its holes are filled; a
client overlay never touches a server-rendered slot.
It follows the refusing `end` node's pick (#22450, PR #22525,
`faf6348508`) through the same channel. There is no second i18n path. PR
#22555 (`Part of #22507`) keyed the option labels and the terminal
toasts. This PR keys the last owed screen slot, so it closes the card.
The approval node's `decisionOutputs[].label` row stays `owed`, as the
ruling says.
## What changes
**`@objectstack/spec`**
- The `flows` face gains `flows.FLOW.screens.NODE_ID.description`,
beside `screens.NODE_ID.title`. The address of `title` does not change.
- A translated `description` is judged by the one text-slot judge
(`textSlotTemplateRefusal`), the same rule `ScreenConfigSchema` applies
to the source text and the `refusals` message applies to its
translation. A single-brace token is refused, and the message gives its
double-brace spelling. An empty string is accepted, because it is the
untranslated slot `os i18n extract` writes.
- The screen face's `guidance.description` entry is removed. A guidance
entry filed under a declared key can never fire (the alias-integrity
audit in `strict-object.ts` holds that). Its message, "the server picks
both", now lives in the `title` and `description` describes and in the
`flows` docblock, which replaces the "Not keyed" section with "picked by
the engine".
- `flowScreenCopyKey(flowName, nodeId, key)` spells the key the engine
reads. It sits beside `flowRefusalMessageKey`. `FLOW_SCREEN_COPY_KEYS`
is now `['title', 'description']`, so the extractor and the schema pin
pick the key up from the one list.
- `translateFlow` overlays a translated `description` on a flow
DOCUMENT, only where the screen authors one. It still overlays `title`
there. Both are template-level, the same pick the engine makes, and
neither touches a served screen (see Zone 2 item 5 below).
- `resolveFlowScreenTitle` and `FlowScreenLike` are documented as not
for a served `ScreenSpec`. The function stays exported for now, because
the console at the `.objectui-sha` pin still imports it (Post-Task
Checklist step 4).
- `ScreenSpec.title` / `.description` and `AutomationContext.locale` are
documented: a client draws the served copy as served and never overlays
it, and the locale's readers now include the `screen` executor.
- The family pin (`flows-translation-face.test.ts`):
`screen.description` moves from `owed` to `keyed` at
`screens.NODE.description`, and `OWED` is now
`approval.decisionOutputs[].label` alone.
- The liveness ledger: the `flows.screens` row cites the engine as the
reader of the heading and the body text, and carries a dated note for
the transition window. The `refusals` rows are repointed from the
renamed private method.
- `dropped-refinements.baseline.json` declares the 8 published sites
where the new text-slot refinement cannot be stated in JSON Schema.
These are the same 8 schemas where the `refusals` message's refinement
sits (688 to 696 sites, 226 schemas). The build gate requires the
declaration.
- Regenerated: `api-surface/system.json`, `export-origins/system.json`
and `references/system/translation.mdx`.
**`@objectstack/service-automation`**
- `AutomationEngine.renderFlowTextSlot(slot, variables, context)` is the
one translated-template pick, generalized from the refusal's private
`translatedRefusalTemplate`, which is renamed `translatedFlowTemplate`.
`renderRefusalMessage` now calls it with `flowRefusalMessageKey`. Its
argument type `FlowTextSlotTranslation` is exported beside
`RefusalI18nService`.
- The `screen` executor (both branches, the flat field list and the
object form) asks it for `flowScreenCopyKey(context.flowName, node.id,
'title' | 'description')`. The run's locale is
`AutomationContext.locale`, negotiated by `resolveBundleLocale`. Only
then does it render through `renderTextSlot`, so values fill a
translated template.
- **Falls back to the authored template** when there is no locale, no
service, no entry or an empty one, or a translation that does not
compile. In the last case a `warn` names the key. A failure of the
authored template still throws, as before.
- **Two presence rules.** The heading is picked even with no
`config.title`, because the one `title` key covers the node label the
heading falls back to. The body text is picked only where the screen
authors one, because a bundle never adds body text the author did not
write. That is the toasts' rule.
**`@objectstack/lint`**: a translated `description` over a screen that
declares no `config.description` is `translation-target-unknown`
(error).
**`@objectstack/cli`**: `os i18n extract` scaffolds
`screens.NODE_ID.description` for each screen that authors one, seeded
with the authored template, holes and all. The coverage gate demands it
in the `flow` bucket. An unauthored description is not even a seed-less
entry, so a bundle that externalizes one is not demanded in every locale
for a string nothing shows.
**Examples and docs**: `app-todo` zh-CN and ja-JP translate
`success_screen.description`, and `app-crm` zh-CN translates its three
screen descriptions. Without these, `check:i18n-coverage` grows past its
baseline. `content/docs/ui/translations.mdx` stops saying a description
renders as authored. The unreleased sibling changeset from #22555 had a
bullet saying a translated `description` is still refused by the schema;
that bullet now points at this entry.
## Zone 2: the PM's mechanism assumptions, measured at `d748ae80af`
1. **Confirmed.** `screen-nodes.ts:173` defined `text`, which calls
`renderTextSlot(v, variables)` and nothing else, used at `:201`-`:202`
(object form) and `:285`-`:286` (flat screen), and no i18n reader was
asked.
2. **Confirmed.** The channel is `setI18nServiceSource`
(`engine.ts:4881`). The refusal's pick was `translatedRefusalTemplate`
(`:11114`), called by `renderRefusalMessage` (`:11087`). It was private,
so the executor could not reach it. It is now the shared public
`renderFlowTextSlot`, which reads through the same `i18nServiceSource`
field. The run's locale at a screen node is the executor's
`context.locale`, the run context `resolveRunContext` builds (`:6160`).
That context is persisted with a suspended run, so a resumed leg reads
the starter's locale. The flow name is `context.flowName`, stamped at
the same construction point.
3. **Confirmed.** At `d748ae80af`, the `flows` face keyed
`screens.NODE_ID.title`, and `guidance.description` refused
`description` (`translation.zod.ts:1352`). The family pin listed
`screen.description` as `owed` (`flows-translation-face.test.ts:124`,
`OWED` at `:179`).
4. **Confirmed.** `walkScreenFlows` iterates `FLOW_SCREEN_COPY_KEYS`
(`i18n-extract.ts:1884`), so adding the key to the list adds the
skeleton and coverage rows. Coverage needs one extra rule here: no
seed-less entry for an unauthored description.
5. **`translateFlow` applies a screen `title` today**, but on the flow
DOCUMENT's `config.title`, which is the template, not the served slot.
- At the `.objectui-sha` pin `20c6d351a`, objectui's only call is
`translateFlow({ name, label })` (`FlowRunner.tsx:274`), which passes no
nodes.
- So the ruling's "a client overlay never touches a server-rendered
slot" requires no change to `translateFlow`. It gains `description` at
the same template level, so the document translator stays complete over
the declared face.
- The served-slot overlay the ruling retires is `resolveFlowScreenTitle`
in objectui's `localizeScreen` (`FlowRunner.tsx:252`). That is the
objectui half; see below.
## Verification
Readings are at head `a42541293c` (merge of `origin/main` `18d999031b`)
unless named.
- **Tests**, each under the verify lock:
- `@objectstack/spec` local tier: 640 files, 19105 passed and 1 todo.
- `@objectstack/spec` repo tier: 54 files, 915 passed.
- `@objectstack/service-automation`: 182 files, 2298 passed, including
the new `screen-copy-translation.test.ts` (13 tests).
- `@objectstack/lint`: 134 files, 6122 passed.
- `@objectstack/cli` unit tier: 277 files, 4109 passed. The integration
tier is declared to CI.
- **Typecheck**: `pnpm --filter PKG run typecheck` exits 0 for all four
packages, test layers included. The spec test layer holds 52 files / 246
errors / 135 pinned signatures, unchanged.
- **Ablation** of the executor's description pick, at `5a2c8649ee`.
- Mutation, through `scripts/ablation-replace.mjs`: in
`screen-nodes.ts`, `screenText('description', cfg.description, 'the body
text')` became `renderTextSlot(cfg.description, variables)`. The anchor
went x1 to x0, and the blob went `5ac2066c` to `f4aa30f5`.
- Predicted: red on the 5 translated-description pins, green on the 8
fallback pins. Observed: `5 failed / 8 passed` (the zh-CN render, the
`zh` negotiation, the resumed leg, the object-form screen, the
broken-translation warn).
- Restored: blob `5ac2066c` matches HEAD, and `git diff HEAD` is empty.
- No dist step was needed: the test imports the executor through
`./builtin/index.js`, which is source.
- **Generated**: `pnpm --filter @objectstack/spec check:generated`
reports "All 15 generated artifacts are up to date", after the merge and
the rebuild.
- **Gates**: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 118 commands at `a42541293c`. 117
were run with their exit codes recorded. `--ran` reports 117 run, 0
NOT-MEASURED and 1 UNRUN: `check:dual-build-cjs-loads`, left unrun by
the dispatch because it needs a whole-workspace build.
- 115 exited 0. Two of them first refused (exit 3, PREREQUISITE NOT MET)
and exited 0 after the named build: `check:skill-examples` after
building `@objectstack/client-react`, and `check:i18n-coverage` after
building the example closure. The latter reads "OK (13 configs, 621
baselined, none new)".
- `check-empty-changeset` exits 1 by design. See "A pending release
note, corrected" below.
- `check:platform-checklist` exits 1, and that is main's state, not this
diff. Its ABSENT SYMBOL lines name
`packages/metadata-protocol/src/protocol.ts` and
`packages/services/service-storage/src/attachment-access-hooks.ts`, and
the checklist tree plus both files diff empty against `origin/main`
`e22315238f`. The `canEdit` line is #22557.
- **ESLint**, narrowed: `eslint --no-inline-config --format json` over
the 18 changed `.ts` files reports 18 results, 0 errors and 0 warnings.
An ignored file would surface as a warning.
- `eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move a verdict on an
untouched file.
- **Size**: 1,051 changed lines, generated files included.
## A pending release note, corrected (`check-empty-changeset` stays red)
`.changeset/22507-flow-options-toasts-translation.md` is #22555's
unreleased note. Its last bullet said a screen's `description` "is still
refused by the schema". This PR makes that sentence false, and both
notes would ship in the same release. So the bullet now points at this
PR's entry. Restoring it from the base would publish the false sentence.
The gate's own text names this case, a deliberate correction, and asks
for confirmation on the PR rather than a restore. The other bullets and
the frontmatter of that note are unchanged.
## The objectui consumer half
This is filed into objectui#12075, as the claim records. At the pin
`20c6d351a`, `FlowRunner.tsx` has to change in these places:
- `:252`: drop `const title = resolveFlowScreenTitle(...)` and return `{
...screen, fields }`.
- `:162`: drop the import.
- The docs at `:83`-`:96`, `:106`-`:108`, `:119`-`:122`, `:240`-`:242`
and the JSX comment at `:543`-`:544`.
- Add no `description` overlay.
- `__tests__/FlowRunner.flowsTranslation-5920.test.tsx` pins the heading
overlay (`:117`, `:168`) and has to pin the served heading drawn as
served.
Until that lands, the runner replaces a served, already-translated
heading with the translated template. That draws a hole literally
exactly where it did before this PR, so the window adds no regression.
## Acceptance notes
- **The `title` translation is not judged by the text-slot judge.**
Judging it would narrow what the face accepts today, which conflicts
with the dispatched `Clause-②: yes (widening)`. A single-brace token in
a translated heading still renders literally, as it did under the client
overlay. The report's open question asks the seat whether to follow up.
- `resolveFlowScreenTitle` retires once the `.objectui-sha` pin carries
the objectui change. The function has no other caller.
- `approval.decisionOutputs[].label` stays `owed`, as pinned.
- The claim's file surface did not name these files:
`packages/spec/src/contracts/automation-service.ts` (docblocks),
`packages/spec/dropped-refinements.baseline.json`,
`content/docs/ui/translations.mdx`, the `app-crm` zh-CN bundle and the
#22555 changeset. Each is a mechanical follow-through of the same
change.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent ad14957 commit d25b187
29 files changed
Lines changed: 882 additions & 169 deletions
File tree
- .changeset
- content/docs
- references/system
- ui
- examples
- app-crm/src/translations
- app-todo/src/translations
- packages
- cli
- src/utils
- test
- lint/src
- services/service-automation/src
- builtin
- spec
- api-surface
- export-origins
- liveness
- src
- contracts
- system
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
260 | 260 | | |
261 | 261 | | |
262 | 262 | | |
263 | | - | |
| 263 | + | |
264 | 264 | | |
265 | 265 | | |
266 | 266 | | |
| |||
461 | 461 | | |
462 | 462 | | |
463 | 463 | | |
464 | | - | |
| 464 | + | |
465 | 465 | | |
466 | 466 | | |
467 | 467 | | |
| |||
623 | 623 | | |
624 | 624 | | |
625 | 625 | | |
626 | | - | |
| 626 | + | |
627 | 627 | | |
628 | 628 | | |
629 | 629 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
80 | 80 | | |
81 | 81 | | |
82 | 82 | | |
83 | | - | |
| 83 | + | |
84 | 84 | | |
85 | 85 | | |
86 | 86 | | |
| |||
390 | 390 | | |
391 | 391 | | |
392 | 392 | | |
393 | | - | |
394 | | - | |
395 | | - | |
396 | | - | |
397 | | - | |
398 | | - | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
399 | 405 | | |
400 | 406 | | |
401 | 407 | | |
402 | 408 | | |
403 | 409 | | |
404 | | - | |
405 | | - | |
406 | | - | |
407 | | - | |
408 | | - | |
409 | | - | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
410 | 415 | | |
411 | 416 | | |
412 | 417 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
218 | 218 | | |
219 | 219 | | |
220 | 220 | | |
221 | | - | |
222 | | - | |
223 | | - | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
224 | 230 | | |
225 | 231 | | |
226 | 232 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
112 | 112 | | |
113 | 113 | | |
114 | 114 | | |
115 | | - | |
| 115 | + | |
116 | 116 | | |
117 | 117 | | |
118 | 118 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
120 | 120 | | |
121 | 121 | | |
122 | 122 | | |
123 | | - | |
| 123 | + | |
124 | 124 | | |
125 | 125 | | |
126 | 126 | | |
| |||
0 commit comments