From 455f50ad1d551f7ace0c39b8c2414276e3c8f371 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:16:00 +0000 Subject: [PATCH 1/9] =?UTF-8?q?fix(spec):=20the=20flows=20translation=20le?= =?UTF-8?q?dger=20rows=20go=20live=20=E2=80=94=20the=20flow=20runner=20rea?= =?UTF-8?q?ds=20flows.FLOW.label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The objectui runner reads the translated flow label at the .objectui-sha pin 0abd4f9f8 (objectui 39a3e91fa), so flows.children.label moves planned -> live and the flows container, both children now live, flips with it and drops authorWarn and authorHint. state-counts/translation.md regenerated. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../spec/liveness/state-counts/translation.md | 2 +- packages/spec/liveness/translation.json | 21 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index d260eb70f72..8738431449d 100644 --- a/packages/spec/liveness/state-counts/translation.md +++ b/packages/spec/liveness/state-counts/translation.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `translation` | 25 | 0 | 0 | 0 | 1 | 26 | +| `translation` | 26 | 0 | 0 | 0 | 0 | 26 | diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 5d186ac954a..e8bc04eb2d8 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -113,15 +113,18 @@ "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). 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:636` had rotted onto `function lookupAppAttr`, the APP group's reader, ~226 lines above the page ones. Re-closed by hand against 8cb96ec41." }, "flows": { - "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` / `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.", + "status": "live", + "verifiedAt": "2026-10-05", + "evidenceScope": "cross-repo", + "evidence": "packages/spec/src/system/i18n-resolver.ts#translateFlow (`flows..label`); packages/spec/src/system/i18n-resolver.ts#resolveFlowScreenTitle (`flows..screens..title`); objectui @0abd4f9f8: packages/app-shell/src/views/FlowRunner.tsx#displayFlowLabel (the flow's name in the runner header and the completion toast); objectui @0abd4f9f8: packages/app-shell/src/views/FlowRunner.tsx#localizeScreen (each screen's heading and per-field copy)", "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." + "status": "live", + "verifiedAt": "2026-10-05", + "evidenceScope": "cross-repo", + "evidence": "packages/spec/src/system/i18n-resolver.ts#translateFlow (reads `flows..label` through `lookupFlowLabel` and keeps the label it is handed when the bundle has none); objectui @0abd4f9f8: packages/app-shell/src/views/FlowRunner.tsx#displayFlowLabel (`translateFlow` handed the served `flowLabel` as the authored label, then `flowName` when that is empty: the active language's `flows..label`, then the served label, then the API name); objectui @0abd4f9f8: packages/app-shell/src/views/FlowRunner.tsx#FlowRunner (draws that name in the dialog header above the step's own heading, and hands it as `flow` to the `flowRunner.completed` completion toast, which shows when the flow authored no `successMessage`); objectui @0abd4f9f8: packages/app-shell/src/views/FlowRunner.tsx#activeFlowsBundle (the active language's `flows` group out of the i18next `translation` resource tree, the same read the `screens` overlay uses)", + "producer": "packages/services/service-automation/src/engine.ts#AutomationEngine (copies the flow definition's authored `label` onto every run result as `flowLabel`, the string the translation overlays; declared as `AutomationResult.flowLabel` in packages/spec/src/contracts/automation-service.ts); objectui @0abd4f9f8: packages/app-shell/src/utils/flowResponse.ts#interpretFlowResponse (lifts the served `flowLabel` off the trigger and resume 200 bodies); objectui @0abd4f9f8: packages/app-shell/src/hooks/useConsoleActionRuntime.tsx#useConsoleActionRuntime, packages/app-shell/src/views/RecordDetailView.tsx#RecordDetailView and apps/console/src/pages/developer/FlowRunsPage.tsx#FlowRunsPage (the three hosts open the runner with that `flowLabel`); 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 with no group filter); objectui @0abd4f9f8: apps/console/src/loadLanguage.ts#loadLanguage, packages/i18n/src/utils/spec-translations.ts#transformSpecTranslations and packages/i18n/src/provider.tsx#I18nProvider (the delivery chain the `screens` row cites, into the resource bundle `activeFlowsBundle` reads)", + "note": "Overlays `Flow.label`. FLIPPED planned → live 2026-10-05. The reader landed client-side in objectui 39a3e91fa, and the `.objectui-sha` pin 0abd4f9f8 carries it (`git merge-base --is-ancestor 39a3e91fad 0abd4f9f8769` answers yes), so every objectui pointer above was read at the pin this repo builds against. The name a user reads for the flow resolves as the active language's `flows..label`, then the authored label the engine serves as `AutomationResult.flowLabel` (landed in commit 5363e2dc64), then the API name. It is drawn in the runner's dialog header, above the step heading, and in the completion toast in place of the API name; an authored `successMessage` still wins the toast and stays outside this face by ruling. The read goes through the spec's own `translateFlow`, from the same bundle the screen copy is read from, so the address is the spec's and there is no second loader. It was `planned` (verified 2026-08-11) and declared per key rather than inherited, because the two halves resolve through different consumers and could go `live` on different days. They did: `screens` on 2026-09-27, `label` here." }, "screens": { "status": "live", @@ -129,10 +132,10 @@ "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 @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": "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`. 2026-10-05: the `⚠️ NOT flipped with it` clause above is superseded. The `label` child is `live` now (objectui's FlowRunner reads it at the pin 0abd4f9f8), so the container flipped to `live` and dropped `authorWarn` and `authorHint`; see the container note." } }, - "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." + "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. 2026-10-05: the `label` child FLIPPED to `live` (objectui's FlowRunner names the flow by `flows..label`, then the served `flowLabel`, then the API name, read at the `.objectui-sha` pin 0abd4f9f8; see that row). Both children are `live`, so the container flips with them, and `authorWarn` and `authorHint` are dropped: a `live` row refuses them, and no half of the group is unread any more. Dropping the bit switches the CLI i18n coverage gate's whole-group `flows.*` demand back on, which is what that gate was built to do on this day." }, "metadataForms": { "status": "live", From 719877c4d7a8ab4137f17dacb0201098b8f42f19 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:19:55 +0000 Subject: [PATCH 2/9] docs(spec,ui): the flows translation group reads as applied in both halves translations.mdx's boundary bullet and the flows TSDoc in translation.zod.ts said the flow's own label was read by nothing; the runner now names the flow by flows.FLOW.label in its header and completion toast. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- content/docs/ui/translations.mdx | 42 ++++++++++----------- packages/spec/src/system/translation.zod.ts | 13 ++++--- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 9f745da1ed1..d9307467242 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -382,27 +382,27 @@ 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 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. 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 - scaffold them into your bundle — both read the same `planned` row. Without - that, the two halves of a single `os lint` run contradicted each other: - omitting the keys was reported as a coverage gap, and adding them was reported - as authoring a group nothing reads. If you author the copy anyway you get the - liveness warning and nothing else — it is telling you the truth, not asking - you to delete a key you will need later. The day the runner lands and the row - flips to `live`, both the coverage report and the extract skeleton pick the - group up on their own; there is no flag to turn on. +- **The `flows` group is applied by the console's screen-flow runner, and its + face is deliberately small.** The keys are addressed the way the runner + resolves them — flow name, screen node id, screen field name — and both + halves render in the active locale: + - `screens` is applied to the screen copy: each screen's `title`, and each + field's `label`, `placeholder` and `inlineHelpText`; + - the flow's own `label` names the flow in the runner's header, above the + step's heading, and in the completion toast. A locale the bundle does not + cover shows the label authored on the flow, and a backend that serves no + label shows the flow's API name. + + Three strings stay outside the group. A screen's `description` renders as + authored. A `successMessage` authored on the flow replaces the completion + sentence and renders as authored too, so the translated label appears in the + toast only when the flow declares none. The runner's own chrome — the Cancel + and Submit buttons — belongs to the console's message catalog rather than your + app's bundle. + + **The tooling asks for these keys like any other group's.** `os lint` reports + a missing `flows.*` key against `supportedLocales`, and `os i18n extract` + scaffolds the group into your 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 diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 4d608f3da35..8bdef8c3164 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -1236,13 +1236,14 @@ const appTranslationDataShape = () => ({ * ruling on #7646). * * The runner half was a separate, downstream change, and it has landed - * client-side for the per-screen copy. objectui's `FlowRunner` reads + * client-side for both halves of the group. objectui's `FlowRunner` reads * `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. + * `FLOW_SCREEN_FIELD_COPY_KEYS` (`label`, `placeholder`, `inlineHelpText`). + * It reads `label` through `translateFlow` to name the flow in its header + * and in the completion toast, falling back to the label the engine serves + * as `AutomationResult.flowLabel` and then to the flow's API name. Both were + * measured at the `.objectui-sha` pin `0abd4f9f8`. See the `flows` rows in + * `liveness/translation.json`: the group and both its children are `live`. */ flows: z.record(z.string(), strictObject({ surface: 'this flow translation', From 60cb125c5f17b3412d8695490d1f189251f5ee20 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:51:39 +0000 Subject: [PATCH 3/9] chore(changeset): @objectstack/spec patch for the flows translation group going live Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .changeset/20318-flows-translation-live.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/20318-flows-translation-live.md diff --git a/.changeset/20318-flows-translation-live.md b/.changeset/20318-flows-translation-live.md new file mode 100644 index 00000000000..d1b8fc74c85 --- /dev/null +++ b/.changeset/20318-flows-translation-live.md @@ -0,0 +1,12 @@ +--- +"@objectstack/spec": patch +--- + +Liveness ledger: the `flows` translation group is `live`, and so are both its children. The console's screen-flow runner now names the flow by `flows..label` in the active language, in the runner's header and in its completion toast. A locale the bundle does not cover shows the label authored on the flow, and then the flow's API name. `screens` was already `live`. + +Clause-②: no + +- The `flows` row drops `authorWarn` and its `authorHint`. `os lint` and `os validate` no longer warn `liveness-planned-property` on a bundle that authors `flows`. A warning is not a refusal, so the accept set is unchanged. +- Dropping that bit switches on the CLI's i18n coverage demand for `flows.*`. `os lint` now reports a `flows..*` key that a supported locale is missing as `i18n/missing-flow`, and `os i18n extract` scaffolds the group into the bundle. Under `--i18n-strict` a missing key is an error: translate it, or run `os i18n extract` to scaffold it. +- The `flows` TSDoc in `translation.zod.ts` and the translations guide's boundary note now say that both halves are applied. +- ⛔ No schema, parse, export or accept-set change. From bffb933a7cfd9c25f44ff01baaa6f80dd02cc7ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:12:15 +0000 Subject: [PATCH 4/9] feat(examples): translate the three screen wizards' flows copy now that the group is read crm_convert_lead_wizard, showcase_reassign_wizard and quick_add_task gain their flows.FLOW.label and screen copy in every supported locale, the 21 strings the coverage gate demands for flows that can open the runner. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../src/translations/crm.translation.ts | 12 ++++++++++++ .../src/system/translations/index.ts | 13 +++++++++++++ examples/app-todo/src/translations/ja-JP.ts | 19 +++++++++++++++++++ examples/app-todo/src/translations/zh-CN.ts | 19 +++++++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/examples/app-crm/src/translations/crm.translation.ts b/examples/app-crm/src/translations/crm.translation.ts index 125cb7301cf..253ed73aecc 100644 --- a/examples/app-crm/src/translations/crm.translation.ts +++ b/examples/app-crm/src/translations/crm.translation.ts @@ -210,6 +210,18 @@ export const CrmTranslationBundle = defineTranslationBundle({ }, }, }, + // The lead-conversion wizard: the flow's name in the runner header and + // completion toast, and each screen's heading. + flows: { + crm_convert_lead_wizard: { + label: '将线索转化为客户和商机', + screens: { + screen_already_converted: { title: '已转化' }, + screen_account: { title: '第 1 步,共 2 步 · 客户' }, + screen_opportunity: { title: '第 2 步,共 2 步 · 商机' }, + }, + }, + }, messages: { 'crm.lead.convert.success': '线索已成功转化为商机。', 'crm.lead.convert.error': '线索转化失败,请重试。', diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index f0517049b56..b4d2ccf091b 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -1088,5 +1088,18 @@ export const ShowcaseTranslationBundle = { }, }, }, + // The reassign wizard: the flow's name in the runner header and + // completion toast, its one screen's heading and its field label. + flows: { + showcase_reassign_wizard: { + label: '重新分配任务', + screens: { + collect: { + title: '新负责人', + fields: { new_assignee: { label: '新负责人' } }, + }, + }, + }, + }, }, }; diff --git a/examples/app-todo/src/translations/ja-JP.ts b/examples/app-todo/src/translations/ja-JP.ts index bf87e2ecd37..9b6777bc149 100644 --- a/examples/app-todo/src/translations/ja-JP.ts +++ b/examples/app-todo/src/translations/ja-JP.ts @@ -91,6 +91,25 @@ export const jaJP: TranslationData = { description: '個人タスク管理アプリケーション', }, }, + // The quick-add wizard: the flow's name in the runner header and completion + // toast, each screen's heading and each field label. + flows: { + quick_add_task: { + label: 'タスクをすばやく追加', + screens: { + screen_1: { + title: 'タスクの詳細', + fields: { + subject: { label: 'タスクの件名' }, + priority: { label: '優先度' }, + dueDate: { label: '期日' }, + category: { label: 'カテゴリ' }, + }, + }, + success_screen: { title: 'タスクを作成しました' }, + }, + }, + }, // Single-segment `messages` ids — `t()` walks the dot path, so an id // containing a dot resolves to nothing; see the `en` bundle (#18566). messages: { diff --git a/examples/app-todo/src/translations/zh-CN.ts b/examples/app-todo/src/translations/zh-CN.ts index 00f66f2284d..5dbada2b55c 100644 --- a/examples/app-todo/src/translations/zh-CN.ts +++ b/examples/app-todo/src/translations/zh-CN.ts @@ -99,6 +99,25 @@ export const zhCN: TranslationData = { description: '个人任务管理应用', }, }, + // The quick-add wizard: the flow's name in the runner header and completion + // toast, each screen's heading and each field label. + flows: { + quick_add_task: { + label: '快速添加任务', + screens: { + screen_1: { + title: '任务详情', + fields: { + subject: { label: '任务主题' }, + priority: { label: '优先级' }, + dueDate: { label: '截止日期' }, + category: { label: '分类' }, + }, + }, + success_screen: { title: '任务已创建' }, + }, + }, + }, // Single-segment `messages` ids — `t()` walks the dot path, so an id // containing a dot resolves to nothing; see the `en` bundle (#18566). messages: { From 68f653ff7cb893c714ed86c2289eead64d9f763f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:44:36 +0000 Subject: [PATCH 5/9] docs(cli,spec): the flows gating and translateFlow comments describe the live flows row Comments only. The authorWarnedTranslationGroups docblock and the extract surface header, the flow-screen coverage test header and translateFlow's docblock said the flow label was unread and the row planned. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- packages/cli/src/utils/i18n-extract.ts | 32 ++++++++++--------- .../test/i18n-flow-screen-coverage.test.ts | 30 ++++++++--------- packages/spec/src/system/i18n-resolver.ts | 9 +++--- 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index e1260e9aac1..d2a52274442 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -107,9 +107,10 @@ * flows..screens..title (#7646 / #11287) * flows..screens..fields..label * flows..screens..fields..placeholder - * ^ gated: `flows` is `planned` + `authorWarn` in the liveness ledger, so - * these are walked only once the row goes `live` (#11624 — see - * `authorWarnedTranslationGroups`) + * flows..screens..fields..inlineHelpText + * ^ walked because the ledger's `flows` row is `live` and warns no + * author; a group the ledger does warn on is left out of the walk + * (see `authorWarnedTranslationGroups`) * metadataForms..label / .description * metadataForms..sections.
.label / .description * metadataForms..fields..label / .helpText / .placeholder @@ -1067,8 +1068,9 @@ function walkObjectTabs(config: any, out: ExpectedEntry[]): void { } /** - * Translation groups the shipped liveness ledger warns an author for authoring - * — today exactly `flows` (`status: planned`, `authorWarn: true`). + * Translation groups the shipped liveness ledger warns an author for authoring. + * Today the set is empty: `flows`, the one group ever warned, is `live` now + * that both its halves are read, so nothing is gated. * * ## Why the walk surface is gated on this at all (#11624) * @@ -1083,20 +1085,20 @@ function walkObjectTabs(config: any, out: ExpectedEntry[]): void { * `i18n/missing-*` family. Under `--i18n-strict` the demand side is an error, * so a project could be *forced* to author keys it is then warned for. * - * ⛔ The warn side is not the bug and must not be softened. Only part of the - * group is read: the console's screen-flow runner reads `screens`, but the - * flow's own `label` is read by nothing yet (#20318), so a translated flow - * label really is stored and never shown. The warn is group-level, so it still - * covers the whole group. The demand is the half that is premature. + * ⛔ The warn side was not the bug, and it was not softened. While the + * console's screen-flow runner read only `screens`, a translated flow `label` + * really was stored and never shown, and the group-level warn said so. The + * demand was the premature half, and it is the half this gate holds back. + * The runner now names the flow by `flows..label` too, so the row is + * `live` and the warn and the gate are gone together. * * ## Shape * * Group-general, not `flows`-specific, and read from the ledger rather than a - * switch of our own: the day the row flips to `live` (dropping its - * `authorWarn`; for `flows` that waits on #20318), the bucket turns itself back - * on with no edit here — and any FUTURE group that acquires a warn is covered on - * the day it is marked, rather than re-opening this collision one group at a - * time. + * switch of our own: when the `flows` row flipped to `live` and dropped its + * `authorWarn`, the bucket turned itself back on with no edit here — and any + * FUTURE group that acquires a warn is covered on the day it is marked, rather + * than re-opening this collision one group at a time. * * The join is on the group (`path[0]`) and stops there deliberately: for * file-authored bundles the warn side only ever fires at that depth. Its diff --git a/packages/cli/test/i18n-flow-screen-coverage.test.ts b/packages/cli/test/i18n-flow-screen-coverage.test.ts index f9a4fa3a630..5b8f52bffe0 100644 --- a/packages/cli/test/i18n-flow-screen-coverage.test.ts +++ b/packages/cli/test/i18n-flow-screen-coverage.test.ts @@ -29,26 +29,24 @@ // the spec-exported key face` test below pins list↔walker on this side, so a // hand-copied list here fails rather than silently offering a key nothing reads. -// ## Why this file simulates a `live` ledger row (#11624) +// ## Why this file simulates a `live` ledger row // // Everything below is the behaviour of the flow bucket ITSELF — which keys the // walker harvests, how the coverage report attributes them, what the skeleton -// looks like. None of it changed in #11624. What changed is WHEN it runs: the -// `flows` row in `@objectstack/spec/liveness/translation.json` is `planned` + -// `authorWarn`, and `os lint` runs this bucket in the SAME pass as -// `lintLivenessProperties`, so demanding the keys while the ledger warns -// authors for writing them left the author with no move that satisfies both. -// The bucket is now gated on that row, and it turns itself back on the day the -// row flips to `live` (dropping its `authorWarn`; for `flows` that waits on -// #20318). +// looks like. WHEN it runs is the ledger's call: the bucket is gated on the +// `flows` row in `@objectstack/spec/liveness/translation.json`. While that row +// was `planned` + `authorWarn`, `os lint` would have demanded the keys in the +// same pass that warned authors for writing them, leaving no move that +// satisfies both, so the walk left the group out. The row is `live` now (the +// runner reads the flow's own `label` as well as `screens`), and the bucket is +// back on with no edit to the walker. // -// So these pins are re-anchored, not retired: the mock below is the ledger -// warning on nothing, i.e. exactly the post-flip world. Retiring them instead -// would have left the flip with no proof the bucket still works, and a pin that -// "passes" because the walker now emits nothing is the worst of both. The -// GATED half — that none of this reaches an author while the row is `planned` -// — is pinned next door in `i18n-flow-liveness-gate.test.ts`, against the real -// shipped ledger. +// The mock below pins the ledger to "warns on nothing", which today is also +// the real ledger's state. It stays so that these pins keep measuring the +// bucket whatever the shipped ledger says next: a pin that "passes" because +// the walker emits nothing is the worst of both. The GATED half — what the +// walk does for a group the ledger DOES warn on — is pinned next door in +// `i18n-flow-liveness-gate.test.ts`, which also pins the real shipped ledger. // // The mock is fail-loud: if it stopped applying, every `expect(...).toContain` // below would go red rather than silently assert over an empty walk. diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index d42ed6f7f89..7c5a042741a 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -3991,10 +3991,11 @@ export function resolveFlowScreenTitle( * draws, read at the `.objectui-sha` pin `f8a9d0fb`, and the ledger's * `flows.screens` row is `live` citing it. This function stays unregistered * because the server-side route is not the one taken. `flows..label` - * takes the same client side: the authored label it falls back to reaches the - * runner on every run result as `AutomationResult.flowLabel`, but nothing reads - * the translation key on either side yet (the objectui runner half is still to - * land), so its ledger row stays `planned`. + * takes the same client side: `FlowRunner` hands this function the authored + * label the engine serves on every run result as `AutomationResult.flowLabel`, + * and draws the answer (the API name when no label was served) in its header + * and its completion toast, read at the `.objectui-sha` pin `0abd4f9f8`. Both + * `flows` rows in the ledger are `live`. */ export function translateFlow( flow: T, From a58c79698408a1194045f3eb18f939a2051601aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:49:25 +0000 Subject: [PATCH 6/9] test(lint): the translation walk and planned-verdict pins plant their warned group The shipped translation ledger warns on no group now that flows is live, so the locale-keyed walk pins and the planned-with-authorHint verdict read a planted synthetic group row through lintLivenessPropertiesFromLedgerDir. The shipped flows row is pinned silent, and the authorWarnedProperties anti-drift pin asks over every governed translation group and over the object type's warned set, so it cannot pass by iterating nothing. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../lint/src/lint-liveness-properties.test.ts | 165 +++++++++++++----- 1 file changed, 125 insertions(+), 40 deletions(-) diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index f44d0def758..15f7f89c4d1 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -38,6 +38,32 @@ import { const objStack = (obj: Record) => ({ objects: [{ name: 'widget', ...obj }] }); const paths = (findings: { message: string }[]) => findings.map((f) => f.message); + +/** + * The shipped `translation` ledger warns on no group since `flows` went `live` + * (both halves read by the console's screen-flow runner), so every pin below + * that needs a WARNED translation group plants one: a copy of the shipped + * ledger directory whose `translation.json` gains a single synthetic + * `planned` + `authorWarn` group row, read through the same + * `lintLivenessPropertiesFromLedgerDir` seam the synthetic `field` ledger + * further down uses. What those pins hold is the locale-keyed WALK and the + * planned verdict's wording, not any shipped verdict; the shipped `flows` row + * is pinned SILENT on the real ledger beside them. + */ +const SYNTH_WARNED_GROUP = 'synthWarnedGroup'; +const SYNTH_WARNED_GROUP_HINT = 'synthetic — this group row exists only in a test ledger directory'; +const synthWarnedGroup = { probe: { label: '探针' } }; +const plantedTranslationLedgerDir = () => ledgerDirWith((dir) => { + const ledger = JSON.parse(readFileSync(join(dir, 'translation.json'), 'utf8')); + ledger.props[SYNTH_WARNED_GROUP] = { + status: 'planned', + authorWarn: true, + authorHint: SYNTH_WARNED_GROUP_HINT, + }; + writeLedger(dir, 'translation', ledger); +}); +const lintWithPlantedGroup = (stack: Record) => + lintLivenessPropertiesFromLedgerDir(plantedTranslationLedgerDir(), stack); /** The rule id of the finding about `path`, or `undefined` when the rule said nothing about it. */ const ruleOf = (findings: { message: string; rule: string }[], path: string) => findings.find((f) => f.message.includes(`sets \`${path}\``))?.rule; @@ -616,7 +642,9 @@ describe('lintLivenessProperties', () => { // (That enumeration is dated to its own commit and has since lost a member: // `field.relatedListFilter` left the warned set in #19187 when objectui#4664 // reached the pin. The claim it supports — every warned entry is TOP-LEVEL, so - // no dotted subject is left — is unchanged by a top-level row leaving.) + // no dotted subject is left — is unchanged by a top-level row leaving. + // `translation.flows` has left it too, by the same route: it went `live` once + // the runner reading the flow's own `label` reached the pin.) // Filed as **#10262**, which also carries the recommendation not to play this // round a third time (test the WALKER against a synthetic warn map, and leave // the ledger-driven coupling to the assertions that are genuinely about the @@ -820,40 +848,49 @@ describe('lintLivenessProperties', () => { // BROKEN walk, so it would have been green from the day the bug shipped and // pinned nothing. describe('translation bundles are locale-keyed (#11288)', () => { - /** `flows` is the one `authorWarn` row on the shipped `translation` ledger. */ - const flowsGroup = { lead_conversion: { screens: { screen_1: { title: '转化详情' } } } }; + /** + * A `flows` group as an author writes it. `flows` is `live` on the shipped + * ledger, so it is authored below only where the pin is SILENCE; the walk + * pins author the planted {@link SYNTH_WARNED_GROUP} instead. + */ + const flowsGroup = { lead_conversion: { label: '线索转化', screens: { screen_1: { title: '转化详情' } } } }; it('warns on a warned group authored under a locale entry', () => { - const findings = lintLivenessProperties({ - translations: [{ 'zh-CN': { flows: flowsGroup } }], + const findings = lintWithPlantedGroup({ + translations: [{ 'zh-CN': { [SYNTH_WARNED_GROUP]: synthWarnedGroup } }], }); - expect(paths(findings).some((m) => m.includes('`flows`'))).toBe(true); + expect(paths(findings).some((m) => m.includes(`\`${SYNTH_WARNED_GROUP}\``))).toBe(true); expect(findings.map((f) => f.where)).toEqual(["translation bundle #0 · locale 'zh-CN'"]); - expect(findings[0]?.hint).toContain('screen-flow runner'); + expect(findings[0]?.hint).toBe(SYNTH_WARNED_GROUP_HINT); }); // The walk has two levels and both can stop early. Authored on the SECOND // locale of the SECOND bundle, so neither an outer nor an inner walk that // visits only index 0 can pass this. it('reaches every locale of every bundle, not just the first of each', () => { - const findings = lintLivenessProperties({ + const findings = lintWithPlantedGroup({ translations: [ { en: { messages: { 'common.save': 'Save' } } }, { ja: { messages: { 'common.save': '保存' } }, - 'zh-CN': { flows: flowsGroup }, + 'zh-CN': { [SYNTH_WARNED_GROUP]: synthWarnedGroup }, }, ], }); expect(findings.map((f) => f.where)).toEqual(["translation bundle #1 · locale 'zh-CN'"]); }); + // `flows` is among the live groups authored here: the shipped row went + // `live` once the runner read the flow's own `label` as well as `screens`, + // so a half-reverted flip (the row `live` again but still opted in to a + // warning, or `planned` again) surfaces as a finding on this bundle. it('stays silent on a bundle that authors only live groups', () => { const findings = lintLivenessProperties({ translations: [{ 'zh-CN': { objects: { crm_lead: { label: '线索' } }, messages: { 'common.save': '保存' }, + flows: flowsGroup, }, }], }); @@ -869,7 +906,7 @@ describe('lintLivenessProperties', () => { it('the translation silence is a real verdict, not a lint that stopped loading ledgers', () => { const findings = lintLivenessProperties({ objects: [{ name: 'widget', externalSharingModel: 'read' }], - translations: [{ 'zh-CN': { objects: { crm_lead: { label: '线索' } } } }], + translations: [{ 'zh-CN': { objects: { crm_lead: { label: '线索' } }, flows: flowsGroup } }], }); const messages = findings.map((f) => f.message); expect(messages.some((m) => m.includes('externalSharingModel'))).toBe(true); @@ -882,14 +919,17 @@ describe('lintLivenessProperties', () => { // (`packages/spec/src/kernel/metadata-type-schemas.ts:158`), not this // collection. It warns on the broken walk and must not here: // `stack.translations` is `z.record(LocaleSchema, TranslationDataSchema)`, so - // this object would have to mean a locale named `flows` whose value is - // `TranslationData` — a parse error two tiers before this advisory ever runs. + // this object would have to mean a locale named after the group, whose value + // is `TranslationData` — a parse error two tiers before this advisory runs. // Runtime-authored items reach this lint through no door at all: no stack // collection carries them, and the rule is `surfaces: CLI_ONLY`, so it does // not run at the runtime publish gate either. + // Driven against the planted ledger, where the same group authored under a + // locale DOES warn (the first pin of this block): on the shipped ledger no + // translation group warns, so a silence there would pin nothing. it('does not treat a runtime `TranslationItem` shape as a bundle', () => { - const findings = lintLivenessProperties({ - translations: [{ name: 'zh_cn', locale: 'zh-CN', flows: flowsGroup }], + const findings = lintWithPlantedGroup({ + translations: [{ name: 'zh_cn', locale: 'zh-CN', [SYNTH_WARNED_GROUP]: synthWarnedGroup }], }); expect(findings).toEqual([]); }); @@ -897,8 +937,8 @@ describe('lintLivenessProperties', () => { // "Advisory only — returns findings, never throws" is the function's own // contract, and a bundle walk adds two levels that can be malformed. it('never throws on a malformed bundle, and keeps walking past it', () => { - const findings = lintLivenessProperties({ - translations: [null, { 'zh-CN': null }, { en: { flows: flowsGroup } }], + const findings = lintWithPlantedGroup({ + translations: [null, { 'zh-CN': null }, { en: { [SYNTH_WARNED_GROUP]: synthWarnedGroup } }], }); // The bundle is the third list item the author wrote and is reported as // `#1`: since #15636 this walk reads through `recordsOf`, which drops the @@ -1098,8 +1138,10 @@ describe('the array fan-out, against a synthetic warn map (#10262)', () => { // the `dead` branch, so the finding's MESSAGE told the author to remove // something the ledger's own `authorHint`/`note` on the SAME finding said to // keep. `field.relatedListFilter`, `object.externalSharingModel` and -// `translation.flows` are the three shipped rows this hit — two of them still, -// the third only until #19187 flipped `field.relatedListFilter` `live`. +// `translation.flows` are the three shipped rows this hit. Only +// `object.externalSharingModel` still does: `field.relatedListFilter` and +// `translation.flows` have both gone `live`, each once its reader reached the +// `.objectui-sha` pin. // // The real ledgers currently have PLANNED rows and EXPERIMENTAL rows, but — as // this file's other comments document at length (#2377, #3896, #4509) — no @@ -1114,20 +1156,39 @@ describe('dead / experimental / planned / live-elsewhere verdicts are distinct, const oneEntry = (entry: Record) => new Map([['gizmo', entry]]); // ── REAL LEDGER: the three rows the card captured ────────────────────── - it('REAL LEDGER: translation.flows (planned) — planned rule id, non-contradictory message, hint preserved', () => { + // + // `translation.flows` no longer has a planned REAL LEDGER case: it went + // `live` once the console's screen-flow runner read the flow's own `label` + // as well as `screens`, so it warns about nothing and can pin no verdict. + // What its case held is split in two below and nothing is dropped: the + // shipped row's SILENCE on the real ledger, and the planned verdict's wording + // WITH an authorHint (rule id, non-contradictory message, hint shown) on a + // planted translation group, the one shape no shipped row carries any more. + it('REAL LEDGER: translation.flows is `live` — authoring it warns nothing, in a call that still warns', () => { const findings = lintLivenessProperties({ + objects: [{ name: 'widget', externalSharingModel: 'read' }], translations: [{ - 'zh-CN': { flows: { lead_conversion: { screens: { screen_1: { title: '转化详情' } } } } }, + 'zh-CN': { flows: { lead_conversion: { label: '线索转化', screens: { screen_1: { title: '转化详情' } } } } }, }], }); + // Anti-vacuity: the same call, the same ledger load, still raises the one + // planned row in tree, so the silence is a reading and not an unloaded lint. + expect(findings.map((f) => f.message).filter((m) => m.includes('externalSharingModel'))).toHaveLength(1); + expect(findings.filter((f) => f.where.startsWith('translation bundle'))).toEqual([]); + }); + + it('PLANTED: a planned + authorWarn translation group with an authorHint — planned rule id, non-contradictory message, hint preserved', () => { + const findings = lintWithPlantedGroup({ + translations: [{ 'zh-CN': { [SYNTH_WARNED_GROUP]: synthWarnedGroup } }], + }); expect(findings).toHaveLength(1); const [f] = findings; expect(f.rule).toBe('liveness-planned-property'); expect(f.message).not.toContain('dead'); expect(f.message).toContain('is planned'); - // The card's own captured hint — unchanged by this fix, just no longer - // contradicted by the message sitting next to it. - expect(f.hint).toContain('screen-flow runner'); + // The row's own authorHint, shown as written and not contradicted by the + // message sitting next to it. + expect(f.hint).toBe(SYNTH_WARNED_GROUP_HINT); }); // ⚠️ The third of the card's rows, `field.relatedListFilter`, no longer has a @@ -1135,8 +1196,8 @@ describe('dead / experimental / planned / live-elsewhere verdicts are distinct, // the `.objectui-sha` pin, so it warns about nothing and can pin no verdict. // Its silence is pinned in the `field related-list filter (#19187)` block // above, where a half-reverted flip surfaces. The PLANNED branch stays a - // contract test on the two rows that are still planned — `translation.flows` - // just above and `object.externalSharingModel` just below. + // contract test on the one row that is still planned, + // `object.externalSharingModel` just below. it('REAL LEDGER: object.externalSharingModel (planned, no authorHint) — planned rule id, the planned DEFAULT hint, never its ledger note', () => { const findings = lintLivenessProperties({ objects: [{ name: 'widget', externalSharingModel: 'read' }] }); @@ -1343,30 +1404,54 @@ describe('dead / experimental / planned / live-elsewhere verdicts are distinct, // The walker now asks THIS function which groups are warned instead of reading // the same JSON a second time. These pins hold the two readings equal. describe('authorWarnedProperties', () => { - it('reports the `flows` translation group as warned (the row the CLI gate reads)', () => { - expect(authorWarnedProperties('translation').has('flows')).toBe(true); + it('reports no translation group as warned now that `flows` is live (the set the CLI gate reads)', () => { + // `flows` was the one warned group, and it went `live` once the runner read + // both its halves. An equality, so the day a translation group acquires a + // warn this goes red and the CLI's coverage gate is looked at with it. + expect([...authorWarnedProperties('translation')]).toEqual([]); + // The positive the empty answer needs, from the same reader in the same + // process: the one planned + authorWarn row still in tree. + expect(authorWarnedProperties('object').has('externalSharingModel')).toBe(true); }); it('can say no — a live group in the same ledger is absent', () => { - // The negative half of the instrument: `objects` is `live` and sits in the - // same file as the positive above, so a set that swallowed everything or - // returned everything would fail one of the two. + // The negative half of the instrument, beside the positive above (a warned + // `object` row): a set that swallowed everything or returned everything + // would fail one of the two. const warned = authorWarnedProperties('translation'); expect(warned.has('objects')).toBe(false); expect(warned.has('pages')).toBe(false); + expect(warned.has('flows')).toBe(false); }); - it('agrees with the rule itself on every path it names — the anti-drift pin', () => { + it('agrees with the rule itself on every translation group — the anti-drift pin', () => { // The whole point of exporting this is that the demand side and the warn - // side cannot disagree. So: authoring each warned top-level group in a - // locale bundle must actually produce a finding naming that group. A path - // this set reports but the rule stays silent on is the drift that reopens - // the collision from the other end. - for (const path of authorWarnedProperties('translation')) { - if (path.includes('.')) continue; // container rows only — see getNested - const findings = lintLivenessProperties({ - translations: [{ 'zh-CN': { [path]: { probe: { label: 'x' } } } }], - }); + // side cannot disagree. With no translation group warned, a loop over the + // exported set would assert nothing, so this asks over the WHOLE domain: + // every top-level row the shipped `translation` ledger governs is authored + // in one locale entry, and the groups the rule names must be exactly the + // groups this set names, in both directions. + const ledger = JSON.parse(readFileSync(join(shippedLedgerDir(), 'translation.json'), 'utf8')); + const groups = Object.keys(ledger.props); + expect(groups).toContain('flows'); + expect(groups.length).toBeGreaterThanOrEqual(10); + const findings = lintLivenessProperties({ + translations: [{ 'zh-CN': Object.fromEntries(groups.map((g) => [g, { probe: { label: 'x' } }])) }], + }); + const named = groups.filter((g) => findings.some((f) => f.message.includes(`sets \`${g}\``))); + const exported = [...authorWarnedProperties('translation')].filter((p) => !p.includes('.')); + expect(named.sort()).toEqual(exported.sort()); + }); + + it('agrees with the rule itself on every object path it names — the same pin over a non-empty set', () => { + // The direction the translation pin above cannot exercise today ("a path + // this set reports but the rule stays silent on"), held on the type that + // still has a warned row. `true` is authored because the rule warns on a + // boolean only when it is true and on anything else when present. + const warned = [...authorWarnedProperties('object')].filter((p) => !p.includes('.')); + expect(warned.length).toBeGreaterThan(0); + for (const path of warned) { + const findings = lintLivenessProperties({ objects: [{ name: 'widget', [path]: true }] }); expect(findings.map((f) => f.message).join('\n')).toContain(`sets \`${path}\``); } }); From dfa18e7699a98fe145c1a9a7f076388f3bb02588 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:50:52 +0000 Subject: [PATCH 7/9] test(cli): the flows liveness-gate pins read the post-flip ledger The census pin is now the empty set, with a positive from the same reader; the gating, demand, collision and skeleton pins assert what the live flows row produces. The injected-set pin still holds the gate for a warned group. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../cli/test/i18n-flow-liveness-gate.test.ts | 129 ++++++++++-------- 1 file changed, 75 insertions(+), 54 deletions(-) diff --git a/packages/cli/test/i18n-flow-liveness-gate.test.ts b/packages/cli/test/i18n-flow-liveness-gate.test.ts index 62b4f465350..fcb0a28799a 100644 --- a/packages/cli/test/i18n-flow-liveness-gate.test.ts +++ b/packages/cli/test/i18n-flow-liveness-gate.test.ts @@ -7,11 +7,10 @@ // `os lint` computes i18n coverage AND runs the authoring-rule registry (which // includes `lintLivenessProperties`) in a single pass over the same stack. The // `flow` coverage bucket, new in #11615, harvested `flows..label`, -// `flows..screens..title` and the per-field `label`/`placeholder`. The -// `flows` row of `@objectstack/spec/liveness/translation.json` is -// `status: planned` + `authorWarn: true` — only part of the group is read: the -// console's screen-flow runner reads `screens`, but the flow's own `label` is -// read by nothing yet (#20318). +// `flows..screens..title` and the per-field `label`/`placeholder`. While +// the `flows` row of `@objectstack/spec/liveness/translation.json` was +// `status: planned` + `authorWarn: true` (the console's screen-flow runner read +// `screens` but not yet the flow's own `label`), the two rules collided. // // Measured on one stack before the fix: // @@ -24,25 +23,30 @@ // restored. Under `--i18n-strict` the demand side is an ERROR, so a project // could be forced to author keys it is then warned for. // -// ⛔ The warn side is NOT the bug and is not softened here. The flow's own -// `label` is read by nothing yet (#20318), so a translated flow label really is -// stored and never shown — the failure mode `validationMessages` was removed in -// 17.0.0 for. The warn is group-level, so it still covers the whole group. The -// demand is the premature half, and it is what is gated. +// The fix gated the DEMAND on the ledger and left the warn alone: a group the +// ledger warns on is left out of the coverage walk and the extract skeleton. // -// ## What is pinned +// ## What is pinned, now that the row is `live` // -// The gate is group-general and read from the ledger rather than switched on -// `flows` by name, so (a) it self-activates when the row flips to `live` with -// the objectui runner, and (b) any FUTURE group that acquires a warn is covered -// on the day it is marked rather than re-opening this collision one group at a -// time. Both properties are pinned below, and so is the census the finding -// asked for: `flows` is the only warned translation group today. +// The runner names the flow by `flows..label` too, so the row is `live`, +// carries no `authorWarn`, and no translation group is warned. The gate is +// group-general and read from the ledger rather than switched on `flows` by +// name, so it let the group back in on that day with no edit to the walker, +// and any FUTURE group that acquires a warn is gated on the day it is marked. +// Pinned below against the REAL shipped ledger — deliberately, so this file goes +// red the day the ledger moves again and someone has to look at both halves: // -// The post-flip behaviour of the bucket itself lives in -// `i18n-flow-screen-coverage.test.ts`, which simulates a `live` row. This file -// runs against the REAL shipped ledger — deliberately, so it goes red the day -// the row moves and someone has to look at both halves together. +// - the census: no translation group is warned, as an equality; +// - the walk with the ledger's own set is the ungated walk, `flows.*` included, +// and `os lint` reports the untranslated wizard; +// - the collision cannot recur: omitting the keys draws demand and no warning, +// and authoring them draws neither; +// - the extract skeleton carries the group. +// +// The GATED behaviour itself, which no shipped row exercises any more, is held +// by the injected-set pin at the bottom ("gates whatever the ledger names"), +// the planted fixture the census cannot be. The bucket's own key face lives in +// `i18n-flow-screen-coverage.test.ts`. import { describe, it, expect } from 'vitest'; import { @@ -51,7 +55,7 @@ import { extractTranslations, } from '../src/utils/i18n-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; -import { lintLivenessProperties } from '@objectstack/lint'; +import { authorWarnedProperties, lintLivenessProperties } from '@objectstack/lint'; const leadConversion = { name: 'lead_conversion', @@ -114,45 +118,57 @@ const flowDemands = (config: any) => const flowWarnings = (config: any) => lintLivenessProperties(config).filter((f) => f.message.includes('`flows`')); -/** The ledger row is `planned`, so the whole surface is walked but not emitted. */ +/** An injected empty set: the walk as the gate runs it when the ledger warns on no group. */ const UNGATED = { warnedGroups: new Set() }; describe('the liveness gate on the i18n coverage walk', () => { - it('names `flows` — and nothing else — as a warned translation group', () => { - // The census #11624 asked for: is any OTHER `planned` + `authorWarn` group - // in the coverage walker's reach? Today, no. This is an equality, not a - // `toContain`, so the day a second group acquires a warn this goes red and - // the collision is considered before it ships rather than after. - expect([...authorWarnedTranslationGroups()]).toEqual(['flows']); + it('names no translation group as warned — `flows` is live', () => { + // The census, after the flip. An equality, not a `not.toContain`, so the + // day any translation group acquires a warn this goes red and the collision + // is considered before it ships rather than after. + expect([...authorWarnedTranslationGroups()]).toEqual([]); }); - it('can say no: the live groups the walker also harvests are absent from that set', () => { + it('can say no and yes: the reader under the census still answers for a type with a warned row', () => { // A zero-hit result only counts once the same instrument returns a positive - // on a term known present, so the assertion above supplies the positive and - // this one the negatives — every other top-level group the walker emits. + // on a term known present. The census above is now the zero, so the positive + // comes from the ledger reader it wraps, on the one planned + authorWarn row + // still in tree; the negatives are the groups the walker emits. + expect(authorWarnedProperties('object').has('externalSharingModel')).toBe(true); const warned = authorWarnedTranslationGroups(); - for (const live of ['objects', 'apps', 'pages', 'dashboards', 'globalActions', 'metadataForms']) { + for (const live of ['objects', 'apps', 'pages', 'dashboards', 'globalActions', 'metadataForms', 'flows']) { expect(warned.has(live)).toBe(false); } }); - it('emits no `flows.*` key while the row is `planned` — and still walks everything else', () => { - const roots = new Set(collectExpectedEntries(app(false)).map((e) => e.path[0])); + it("walks `flows.*` with the ledger's own set — the same walk as an ungated one", () => { + const keys = (opts?: { warnedGroups: ReadonlySet }) => + collectExpectedEntries(app(false), opts).map((e) => e.path.join('.')).sort(); + expect(keys()).toEqual(keys(UNGATED)); - expect(roots.has('flows')).toBe(false); - // Not an empty-walk artifact: the same call still harvests the live groups. + const roots = new Set(collectExpectedEntries(app(false)).map((e) => e.path[0])); + expect(roots.has('flows')).toBe(true); + // Not a flows-only walk: the same call still harvests the other groups. expect(roots.has('objects')).toBe(true); expect(roots.has('metadataForms')).toBe(true); }); - it('reports no `i18n/missing-flow`, on a tree that still reports its object gaps', () => { + it('reports the untranslated wizard as `i18n/missing-flow`, on a tree that still reports its object gaps', () => { const untranslatedObject = { ...app(false), translations: [{ 'zh-CN': { objects: { crm_lead: { label: '线索' } } } }], }; - expect(flowDemands(app(false))).toEqual([]); - // The report is live — the same run still speaks about the live groups. + expect(flowDemands(app(false)).map((i) => `${i.locale} ${i.key}`).sort()).toEqual([ + 'zh-CN flows.lead_conversion.label', + 'zh-CN flows.lead_conversion.screens.conversion_details.fields.create_opportunity.label', + 'zh-CN flows.lead_conversion.screens.conversion_details.fields.opportunity_name.label', + 'zh-CN flows.lead_conversion.screens.conversion_details.fields.opportunity_name.placeholder', + 'zh-CN flows.lead_conversion.screens.conversion_details.title', + ]); + // The translated wizard draws none, so the demand is the wizard's own gap. + expect(flowDemands(app(true))).toEqual([]); + // The report is live — the same run still speaks about the other groups. expect( computeI18nCoverage(untranslatedObject).issues.some( (i) => i.source === 'field' && i.locale === 'zh-CN', @@ -161,27 +177,32 @@ describe('the liveness gate on the i18n coverage walk', () => { }); it('never lets both rules speak about the same keys — the collision itself', () => { - // Omitting was the branch that drew `i18n/missing-flow`; authoring is the - // branch that draws the liveness warning. Neither branch may now produce - // BOTH, and the demand side is silent on both. - expect(flowDemands(app(false))).toEqual([]); + // Omitting the keys draws the demand and no warning; authoring them draws + // neither. A branch producing BOTH is the collision this file exists for, + // and the second branch is the move the author lacked while the row warned. + expect(flowDemands(app(false)).length).toBeGreaterThan(0); expect(flowWarnings(app(false))).toEqual([]); expect(flowDemands(app(true))).toEqual([]); - // ⛔ The warning is true and stays: the flow's own `label` is read by - // nothing yet, so its translated copy is stored and never shown. Its rule - // id says `planned`, i.e. "keep it", not "remove it". - const warned = flowWarnings(app(true)); - expect(warned.length).toBeGreaterThan(0); - for (const f of warned) expect(f.rule).toBe('liveness-planned-property'); + expect(flowWarnings(app(true))).toEqual([]); + + // Not a lint that stopped loading ledgers: the same rule over the same + // translated app, plus the one warned object row in tree, raises that row. + const witnessed = lintLivenessProperties({ + ...app(true), + objects: [...app(true).objects, { name: 'crm_widget', externalSharingModel: 'read' }], + }); + expect(witnessed.some((f) => f.message.includes('externalSharingModel'))).toBe(true); + expect(witnessed.some((f) => f.message.includes('`flows`'))).toBe(false); }); - it('does not scaffold a skeleton whose every filled-in key would be warned', () => { + it('scaffolds the `flows` group into the extract skeleton — no key of it is warned', () => { const { bundles } = extractTranslations(app(false), { locales: ['en', 'zh-CN'] }); - expect((bundles.en as any).flows).toBeUndefined(); - // `os i18n extract` is the other door into the same trap, so it is gated by - // the same read — but only for the warned group. + expect((bundles.en as any).flows?.lead_conversion?.label).toBe('Convert Lead'); + expect((bundles.en as any).flows?.lead_conversion?.screens?.conversion_details?.title).toBe('Conversion Details'); + // `os i18n extract` is the other door through the same gate, so it reads + // the same set; the other groups are scaffolded as before. expect((bundles.en as any).objects.crm_lead.label).toBe('Lead'); }); From 1a58a0a61042b6a97af0684a4acbe179bd548b0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 10:52:15 +0000 Subject: [PATCH 8/9] fix(cli): demand flows.FLOW.label only for a flow the screen-flow runner can open walkScreenFlows emits the flow-label entry only when the flow has a screen node at any depth of the node universe its screens walk reads. The one reader of the key, the console's FlowRunner, opens only on a run paused at a screen, so a scheduled, record-triggered or API flow with no screen owed a string no surface shows. Pinned both ways: no screen node, no label entry; a screen nested two regions deep still owes it. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- packages/cli/src/utils/i18n-extract.ts | 19 ++- .../test/i18n-flow-screen-coverage.test.ts | 110 +++++++++++++++--- 2 files changed, 109 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index d2a52274442..521163a07d8 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -1825,12 +1825,25 @@ function walkScreenFlows(config: any, out: ExpectedEntry[]): void { if (!flowName) continue; const scope: EntryScope = { flowName }; + const nodes: any[] = collectFlowNodesDeep(flow.nodes); + // `flows..label` — `lookupFlowLabel`'s key. `Flow.label` is required // by the schema, so this is authored text in practice; `pushOptional` // keeps a label-less flow from seeding an empty string anyway. - pushOptional(out, ['flows', flowName, 'label'], flow.label, 'flow', scope); - - const nodes: any[] = collectFlowNodesDeep(flow.nodes); + // + // Demanded only for a flow with a `screen` node, at any depth of the same + // node universe the screens walk below reads. The predicate mirrors the one + // reader of the key: the console's `FlowRunner` names the flow by it, in + // its header and its completion toast, and the runner opens only on a run + // paused at a screen. A flow that can never pause there (record-triggered, + // scheduled, an API flow with no screen) has no surface that shows its + // translated label, so demanding one would ask an author for a string that + // is stored and never read. When a reader of a non-screen flow's label + // lands (a run-result toast, say), this predicate widens in the same change + // as that reader. + if (nodes.some((node) => node && typeof node === 'object' && node.type === SCREEN_NODE_TYPE)) { + pushOptional(out, ['flows', flowName, 'label'], flow.label, 'flow', scope); + } for (const node of nodes) { if (!node || typeof node !== 'object' || node.type !== SCREEN_NODE_TYPE) continue; const nodeId = typeof node.id === 'string' && node.id.length > 0 ? node.id : undefined; diff --git a/packages/cli/test/i18n-flow-screen-coverage.test.ts b/packages/cli/test/i18n-flow-screen-coverage.test.ts index 5b8f52bffe0..021cd4bf402 100644 --- a/packages/cli/test/i18n-flow-screen-coverage.test.ts +++ b/packages/cli/test/i18n-flow-screen-coverage.test.ts @@ -544,23 +544,27 @@ describe('a screen inside an ADR-0031 region (#17511)', () => { // `FLOW_REGION_SLOTS_BY_TYPE`, so a screen-shaped object sitting in its // payload is data, not a flow node, and must not become a bundle key. // This is what consulting the table buys over walking every `body`. - expect( - flowKeys({ - flows: [ - { - name: 'callout', - label: 'Callout', - nodes: [ - { - id: 'post', - type: 'http', - config: { body: { nodes: [{ id: 'not_a_screen', type: 'screen', config: { title: 'Payload' } }] } }, - }, - ], - }, - ], - }), - ).toEqual(['flows.callout.label']); + const callout = { + name: 'callout', + label: 'Callout', + nodes: [ + { + id: 'post', + type: 'http', + config: { body: { nodes: [{ id: 'not_a_screen', type: 'screen', config: { title: 'Payload' } }] } }, + }, + ], + }; + // Not even the flow's own label: the payload object is not a screen node, + // so the flow has none and the runner can never open on it. + expect(flowKeys({ flows: [callout] })).toEqual([]); + // The lit control for that empty answer: the same flow with one real + // screen node is walked, and the payload object still is not. + const confirmed = { ...callout, nodes: [...callout.nodes, { id: 'confirm', type: 'screen', config: { title: 'Confirm' } }] }; + expect(flowKeys({ flows: [confirmed] }).sort()).toEqual([ + 'flows.callout.label', + 'flows.callout.screens.confirm.title', + ]); }); it('collapses a node id repeated at two depths onto its one bundle slot', () => { @@ -599,3 +603,75 @@ describe('a screen inside an ADR-0031 region (#17511)', () => { ).toEqual(['flows.cyclic.label', 'flows.cyclic.screens.real.title']); }); }); + +// ── The flow's own label is demanded only for a flow the runner can open ──── +// +// The console's `FlowRunner` is the one reader of `flows..label`: it +// names the flow in its header and its completion toast, and it opens only on +// a run paused at a screen node. So the walker emits the label entry only for +// a flow with a screen node somewhere in the node universe the screens walk +// reads, at any depth. Both sides are pinned: a flow with no screen node owes +// no label, and a flow whose only screen sits inside a region still owes it. + +/** A scheduled flow whose nodes include a region, and no screen at any depth. */ +const nightlyDigest = { + name: 'nightly_digest', + label: 'Nightly Digest', + type: 'schedule', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'each_owner', type: 'loop', config: { body: { nodes: [{ id: 'mail', type: 'send_email', label: 'Mail' }] } } }, + ], + edges: [], +}; + +/** A flow whose only screen node sits two regions deep: loop body, then a parallel branch. */ +const guardedCapture = { + name: 'guarded_capture', + label: 'Guarded Capture', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'per_item', + type: 'loop', + config: { + body: { + nodes: [ + { + id: 'fan_out', + type: 'parallel', + config: { branches: [{ name: 'ask', nodes: [{ id: 'ask_reason', type: 'screen', config: { title: 'Why?' } }] }] }, + }, + ], + }, + }, + }, + ], + edges: [], +}; + +describe('the flow label is demanded only for a flow the runner can open', () => { + it('emits no `flows..label` entry for a flow with no screen node', () => { + expect(flowKeys({ flows: [nightlyDigest] })).toEqual([]); + }); + + it('still emits it for a flow whose only screen node is region-nested', () => { + expect(flowKeys({ flows: [guardedCapture] }).sort()).toEqual([ + 'flows.guarded_capture.label', + 'flows.guarded_capture.screens.ask_reason.title', + ]); + }); + + it('asks the coverage gate for the screen flow\'s label and not the other\'s, in one tree', () => { + const report = computeI18nCoverage({ + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + flows: [nightlyDigest, guardedCapture], + translations: [{ 'zh-CN': {} }], + }); + const labels = userIssues(report) + .map((i) => i.key) + .filter((k) => k.endsWith('.label') && k.split('.').length === 3) + .sort(); + expect(labels).toEqual(['flows.guarded_capture.label']); + }); +}); From bac6651dd7eafe818d99078ed17d8f595691b16c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 10:52:59 +0000 Subject: [PATCH 9/9] chore(changeset): @objectstack/cli patch for the narrowed flow-label demand The translations guide and the spec changeset say the flow's own label is asked for only when the flow has a screen node. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .changeset/20318-cli-flow-label-demand.md | 7 +++++++ .changeset/20318-flows-translation-live.md | 2 +- content/docs/ui/translations.mdx | 5 ++++- 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 .changeset/20318-cli-flow-label-demand.md diff --git a/.changeset/20318-cli-flow-label-demand.md b/.changeset/20318-cli-flow-label-demand.md new file mode 100644 index 00000000000..c6eaa5a3845 --- /dev/null +++ b/.changeset/20318-cli-flow-label-demand.md @@ -0,0 +1,7 @@ +--- +"@objectstack/cli": patch +--- + +`os lint` and `os i18n extract` ask for a flow's `flows..label` translation only when the flow has a screen node at any depth, the only kind of flow the console's screen-flow runner opens and names, so a scheduled, record-triggered or API flow with no screen no longer draws an `i18n/missing-flow` demand for a label no surface shows. + +Clause-②: no diff --git a/.changeset/20318-flows-translation-live.md b/.changeset/20318-flows-translation-live.md index d1b8fc74c85..8a46684d425 100644 --- a/.changeset/20318-flows-translation-live.md +++ b/.changeset/20318-flows-translation-live.md @@ -7,6 +7,6 @@ Liveness ledger: the `flows` translation group is `live`, and so are both its ch Clause-②: no - The `flows` row drops `authorWarn` and its `authorHint`. `os lint` and `os validate` no longer warn `liveness-planned-property` on a bundle that authors `flows`. A warning is not a refusal, so the accept set is unchanged. -- Dropping that bit switches on the CLI's i18n coverage demand for `flows.*`. `os lint` now reports a `flows..*` key that a supported locale is missing as `i18n/missing-flow`, and `os i18n extract` scaffolds the group into the bundle. Under `--i18n-strict` a missing key is an error: translate it, or run `os i18n extract` to scaffold it. +- Dropping that bit switches on the CLI's i18n coverage demand for `flows.*`. `os lint` now reports a `flows..*` key that a supported locale is missing as `i18n/missing-flow`, and `os i18n extract` scaffolds the group into the bundle. The flow's own `label` is demanded only for a flow with a screen node (see the `@objectstack/cli` entry). Under `--i18n-strict` a missing key is an error: translate it, or run `os i18n extract` to scaffold it. - The `flows` TSDoc in `translation.zod.ts` and the translations guide's boundary note now say that both halves are applied. - ⛔ No schema, parse, export or accept-set change. diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index d9307467242..1ca1c6200b0 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -402,7 +402,10 @@ Honest limits worth knowing before you plan around them: **The tooling asks for these keys like any other group's.** `os lint` reports a missing `flows.*` key against `supportedLocales`, and `os i18n extract` - scaffolds the group into your bundle. + scaffolds the group into your bundle. A flow's own `label` is asked for only + when the flow has a screen node, at any depth, because the runner opens only + on a screen: a scheduled or record-triggered flow with no screen has no + surface that shows its translated label. - **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